diff --git a/packages/core/auth/src/__tests__/auth-manager.test.ts b/packages/core/auth/src/__tests__/auth-manager.test.ts index d84db75fae..480d24682d 100644 --- a/packages/core/auth/src/__tests__/auth-manager.test.ts +++ b/packages/core/auth/src/__tests__/auth-manager.test.ts @@ -1,8 +1,6 @@ -import { vi } from 'vitest'; import { Context } from '@nocobase/actions'; import { Auth, AuthManager } from '@nocobase/auth'; -import Database, { Model } from '@nocobase/database'; -import { MockServer, mockServer } from '@nocobase/test'; +import { Model } from '@nocobase/database'; class MockStorer { elements: Map = new Map(); @@ -29,12 +27,13 @@ class BasicAuth extends Auth { describe('auth-manager', () => { let authManager: AuthManager; + let storer: MockStorer; beforeEach(() => { authManager = new AuthManager({ authKey: 'X-Authenticator', }); - const storer = new MockStorer(); + storer = new MockStorer(); const authenticator = { name: 'basic-test', authType: 'basic', @@ -48,70 +47,25 @@ describe('auth-manager', () => { authManager.registerTypes('basic', { auth: BasicAuth }); const authenticator = await authManager.get('basic-test', {} as Context); expect(authenticator).toBeInstanceOf(BasicAuth); + + storer.set('basic2-test', { name: 'basic2-test', authType: 'basic2', options: {} }); + expect(authManager.get('basic2-test', {} as Context)).rejects.toThrowError('AuthType [basic2] is not found.'); + + await expect(authManager.get('not-exists', {} as Context)).rejects.toThrowError( + 'Authenticator [not-exists] is not found.', + ); + + authManager.setStorer(null); + await expect(authManager.get('any', {} as Context)).rejects.toThrowError('AuthManager.storer is not set.'); }); - describe('middleware', () => { - let app: MockServer; - let db: Database; - let agent; + it('should list types', () => { + authManager.registerTypes('basic', { auth: BasicAuth, title: 'Basic' }); + expect(authManager.listTypes()).toEqual([{ name: 'basic', title: 'Basic' }]); + }); - beforeEach(async () => { - app = mockServer({ - registerActions: true, - acl: true, - plugins: ['users', 'auth', 'acl', 'data-source-manager'], - }); - - // app.plugin(ApiKeysPlugin); - await app.loadAndInstall({ clean: true }); - db = app.db; - agent = app.agent(); - }); - - afterEach(async () => { - await app.destroy(); - }); - - describe('blacklist', () => { - const hasFn = vi.fn(); - const addFn = vi.fn(); - beforeEach(async () => { - await agent.login(1); - app.authManager.setTokenBlacklistService({ - has: hasFn, - add: addFn, - }); - }); - - afterEach(() => { - hasFn.mockReset(); - addFn.mockReset(); - }); - - it('basic', async () => { - const res = await agent.resource('auth').check(); - const token = res.request.header['Authorization'].replace('Bearer ', ''); - expect(res.status).toBe(200); - expect(hasFn).toHaveBeenCalledWith(token); - }); - - it('signOut should add token to blacklist', async () => { - // signOut will add token - const res = await agent.resource('auth').signOut(); - const token = res.request.header['Authorization'].replace('Bearer ', ''); - expect(addFn).toHaveBeenCalledWith({ - token, - // Date or String is ok - expiration: expect.any(String), - }); - }); - - it('should throw 401 when token in blacklist', async () => { - hasFn.mockImplementation(() => true); - const res = await agent.resource('auth').check(); - expect(res.status).toBe(401); - expect(res.text).toContain('token is not available'); - }); - }); + it('should get auth config', () => { + authManager.registerTypes('basic', { auth: BasicAuth, title: 'Basic' }); + expect(authManager.getAuthConfig('basic')).toEqual({ auth: BasicAuth, title: 'Basic' }); }); }); diff --git a/packages/core/auth/src/__tests__/base-auth.test.ts b/packages/core/auth/src/__tests__/base-auth.test.ts new file mode 100644 index 0000000000..f93ef5170f --- /dev/null +++ b/packages/core/auth/src/__tests__/base-auth.test.ts @@ -0,0 +1,135 @@ +import { BaseAuth } from '../base/auth'; +import { vi } from 'vitest'; + +describe('base-auth', () => { + it('should validate username', () => { + const auth = new BaseAuth({ + userCollection: {}, + } as any); + + expect(auth.validateUsername('')).toBe(false); + expect(auth.validateUsername('a')).toBe(false); + expect(auth.validateUsername('a@')).toBe(false); + expect(auth.validateUsername('a.')).toBe(false); + expect(auth.validateUsername('a<')).toBe(false); + expect(auth.validateUsername('a>')).toBe(false); + expect(auth.validateUsername('a"')).toBe(false); + expect(auth.validateUsername('a/')).toBe(false); + expect(auth.validateUsername("a'")).toBe(false); + expect(auth.validateUsername('ab')).toBe(true); + // 16 characters + expect(auth.validateUsername('12345678910111213')).toBe(false); + }); + + it('check: should return null when no token', async () => { + const auth = new BaseAuth({ + userCollection: {}, + ctx: { + getBearerToken: () => null, + }, + } as any); + + expect(await auth.check()).toBe(null); + }); + + it('check: should set roleName to headers', async () => { + const ctx = { + getBearerToken: () => 'token', + headers: {}, + logger: { + error: (...args) => console.log(args), + }, + app: { + authManager: { + jwt: { + decode: () => ({ userId: 1, roleName: 'admin' }), + }, + }, + }, + }; + const auth = new BaseAuth({ + ctx, + userCollection: { + repository: { + findOne: () => ({ id: 1 }), + }, + }, + } as any); + + await auth.check(); + expect(ctx.headers['x-role']).toBe('admin'); + }); + + it('check: should return user', async () => { + const ctx = { + getBearerToken: () => 'token', + headers: {}, + logger: { + error: (...args) => console.log(args), + }, + app: { + authManager: { + jwt: { + decode: () => ({ userId: 1, roleName: 'admin' }), + }, + }, + }, + cache: { + wrap: async (key, fn) => fn(), + }, + }; + const auth = new BaseAuth({ + ctx, + userCollection: { + repository: { + findOne: () => ({ id: 1 }), + }, + }, + } as any); + expect(await auth.check()).toEqual({ id: 1 }); + }); + + it('signIn: should throw 401', async () => { + const ctx = { + throw: vi.fn().mockImplementation((status, message) => { + throw new Error(message); + }), + }; + + const auth = new BaseAuth({ + userCollection: {}, + ctx, + } as any); + await expect(auth.signIn()).rejects.toThrowError('Unauthorized'); + }); + + it('signIn: should return user and token', async () => { + class TestAuth extends BaseAuth { + async validate() { + return { id: 1 } as any; + } + } + + const ctx = { + throw: vi.fn().mockImplementation((status, message) => { + throw new Error(message); + }), + app: { + authManager: { + jwt: { + sign: () => 'token', + }, + }, + }, + }; + + const auth = new TestAuth({ + userCollection: {}, + ctx, + } as any); + + const res = await auth.signIn(); + expect(res.token).toBe('token'); + expect(res.user).toEqual({ id: 1 }); + }); +}); diff --git a/packages/core/auth/src/__tests__/middleware.test.ts b/packages/core/auth/src/__tests__/middleware.test.ts new file mode 100644 index 0000000000..90c50e8626 --- /dev/null +++ b/packages/core/auth/src/__tests__/middleware.test.ts @@ -0,0 +1,68 @@ +import { Database } from '@nocobase/database'; +import { MockServer, mockServer } from '@nocobase/test'; +import { vi } from 'vitest'; + +describe('middleware', () => { + let app: MockServer; + let db: Database; + let agent; + + beforeEach(async () => { + app = mockServer({ + registerActions: true, + acl: true, + plugins: ['users', 'auth', 'acl', 'data-source-manager'], + }); + + // app.plugin(ApiKeysPlugin); + await app.loadAndInstall({ clean: true }); + db = app.db; + agent = app.agent(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + describe('blacklist', () => { + const hasFn = vi.fn(); + const addFn = vi.fn(); + beforeEach(async () => { + await agent.login(1); + app.authManager.setTokenBlacklistService({ + has: hasFn, + add: addFn, + }); + }); + + afterEach(() => { + hasFn.mockReset(); + addFn.mockReset(); + }); + + it('basic', async () => { + const res = await agent.resource('auth').check(); + const token = res.request.header['Authorization'].replace('Bearer ', ''); + expect(res.status).toBe(200); + expect(hasFn).toHaveBeenCalledWith(token); + }); + + it('signOut should add token to blacklist', async () => { + // signOut will add token + const res = await agent.resource('auth').signOut(); + const token = res.request.header['Authorization'].replace('Bearer ', ''); + expect(addFn).toHaveBeenCalledWith({ + token, + // Date or String is ok + expiration: expect.any(String), + }); + }); + + it('should throw 401 when token in blacklist', async () => { + hasFn.mockImplementation(() => true); + const res = await agent.resource('auth').check(); + expect(res.status).toBe(401); + expect(res.text).toContain('token is not available'); + }); + }); +}); diff --git a/packages/core/auth/src/actions.ts b/packages/core/auth/src/actions.ts index 3d3fc98892..5fc65e17f9 100644 --- a/packages/core/auth/src/actions.ts +++ b/packages/core/auth/src/actions.ts @@ -1,3 +1,4 @@ +/* istanbul ignore file -- @preserve */ import { Handlers } from '@nocobase/resourcer'; export const actions = { diff --git a/packages/core/auth/src/auth-manager.ts b/packages/core/auth/src/auth-manager.ts index e1a5221f92..9948eef34d 100644 --- a/packages/core/auth/src/auth-manager.ts +++ b/packages/core/auth/src/auth-manager.ts @@ -85,9 +85,9 @@ export class AuthManager { if (!authenticator) { throw new Error(`Authenticator [${name}] is not found.`); } - const { auth } = this.authTypes.get(authenticator.authType); + const { auth } = this.authTypes.get(authenticator.authType) || {}; if (!auth) { - throw new Error(`AuthType [${name}] is not found.`); + throw new Error(`AuthType [${authenticator.authType}] is not found.`); } return new auth({ authenticator, options: authenticator.options, ctx }); } diff --git a/packages/core/auth/src/base/auth.ts b/packages/core/auth/src/base/auth.ts index 33318e7c3e..1cb4f0389b 100644 --- a/packages/core/auth/src/base/auth.ts +++ b/packages/core/auth/src/base/auth.ts @@ -90,7 +90,7 @@ export class BaseAuth extends Auth { try { user = await this.validate(); } catch (err) { - this.ctx.throw(401, err.message); + this.ctx.throw(err.status || 401, err.message); } if (!user) { this.ctx.throw(401, 'Unauthorized'); diff --git a/packages/core/auth/src/base/jwt-service.ts b/packages/core/auth/src/base/jwt-service.ts index 244be12818..a4af13f4af 100644 --- a/packages/core/auth/src/base/jwt-service.ts +++ b/packages/core/auth/src/base/jwt-service.ts @@ -31,6 +31,7 @@ export class JwtService { return this.options.secret; } + /* istanbul ignore next -- @preserve */ sign(payload: SignPayload, options?: SignOptions) { const opt = { expiresIn: this.expiresIn(), ...options }; if (opt.expiresIn === 'never') { @@ -39,6 +40,7 @@ export class JwtService { return jwt.sign(payload, this.secret(), opt); } + /* istanbul ignore next -- @preserve */ decode(token: string): Promise { return new Promise((resolve, reject) => { jwt.verify(token, this.secret(), (err: any, decoded: any) => { diff --git a/packages/core/cache/src/__tests__/bloom-filter.test.ts b/packages/core/cache/src/__tests__/bloom-filter.test.ts index 12889edf20..e0739caeb3 100644 --- a/packages/core/cache/src/__tests__/bloom-filter.test.ts +++ b/packages/core/cache/src/__tests__/bloom-filter.test.ts @@ -21,4 +21,14 @@ describe('bloomFilter', () => { expect(await bloomFilter.exists('bloom-test', 'hello')).toBeTruthy(); expect(await bloomFilter.exists('bloom-test', 'world')).toBeFalsy(); }); + + it('should mAdd and check', async () => { + await bloomFilter.mAdd('bloom-test', ['hello', 'world']); + expect(await bloomFilter.exists('bloom-test', 'hello')).toBeTruthy(); + expect(await bloomFilter.exists('bloom-test', 'world')).toBeTruthy(); + }); + + it('should return false if not reserved', async () => { + expect(await bloomFilter.exists('not-reserved', 'hello')).toBeFalsy(); + }); }); diff --git a/packages/core/cache/src/__tests__/cache.test.ts b/packages/core/cache/src/__tests__/cache.test.ts index fb20f6d8bc..234a6ef4d2 100644 --- a/packages/core/cache/src/__tests__/cache.test.ts +++ b/packages/core/cache/src/__tests__/cache.test.ts @@ -21,7 +21,44 @@ describe('cache', () => { expect(value).toBe('value'); }); - it('set and get value in object', async () => { + it('should del value', async () => { + await cache.set('key', 'value'); + await cache.del('key'); + const value = await cache.get('key'); + expect(value).toBeUndefined(); + }); + + it('should mset and mget values', async () => { + await cache.mset([ + ['key1', 'value1'], + ['key2', 'value2'], + ]); + const values = await cache.mget('key1', 'key2'); + expect(values).toMatchObject(['value1', 'value2']); + }); + + it('should mdel values', async () => { + await cache.mset([ + ['key1', 'value1'], + ['key2', 'value2'], + ]); + await cache.mdel('key1', 'key2'); + const values = await cache.mget('key1', 'key2'); + expect(values).toMatchObject([undefined, undefined]); + }); + + it('should get all keys', async () => { + await cache.mset([ + [`key1`, 'value1'], + [`key2`, 'value2'], + ]); + const keys = await cache.keys(); + expect(keys.length).toBe(2); + expect(keys).toContain('key1'); + expect(keys).toContain('key2'); + }); + + it('should set and get value in object', async () => { const value = { a: 1 }; await cache.set('key', value); const cacheA = await cache.getValueInObject('key', 'a'); @@ -32,6 +69,16 @@ describe('cache', () => { expect(cacheVal2).toEqual(2); }); + it('should del value in object', async () => { + const value = { a: 1, b: 2 }; + await cache.set('key', value); + await cache.delValueInObject('key', 'a'); + const cacheA = await cache.getValueInObject('key', 'a'); + expect(cacheA).toBeUndefined(); + const cacheB = await cache.getValueInObject('key', 'b'); + expect(cacheB).toEqual(2); + }); + it('wrap with condition, useCache', async () => { const obj = {}; const get = () => obj; diff --git a/packages/core/cache/src/bloom-filter/redis-bloom-filter.ts b/packages/core/cache/src/bloom-filter/redis-bloom-filter.ts index 8e0cb2ea42..374f40aa8e 100644 --- a/packages/core/cache/src/bloom-filter/redis-bloom-filter.ts +++ b/packages/core/cache/src/bloom-filter/redis-bloom-filter.ts @@ -1,3 +1,4 @@ +/* istanbul ignore file -- @preserve */ import { RedisStore } from 'cache-manager-redis-yet'; import { BloomFilter } from '.'; import { Cache } from '../cache'; diff --git a/packages/core/database/src/__tests__/sql-collection/infer-fields.test.ts b/packages/core/database/src/__tests__/sql-collection/infer-fields.test.ts index 02711cb162..585b0a157b 100644 --- a/packages/core/database/src/__tests__/sql-collection/infer-fields.test.ts +++ b/packages/core/database/src/__tests__/sql-collection/infer-fields.test.ts @@ -42,6 +42,21 @@ describe('infer fields', () => { await db.close(); }); + it('should infer for select *', async () => { + const model = class extends SQLModel {}; + model.init(null, { + modelName: 'users', + tableName: 'users', + sequelize: db.sequelize, + }); + model.database = db; + model.sql = `select * from users`; + expect(model.inferFields()).toMatchObject({ + id: { type: 'bigInt', source: 'users.id' }, + nickname: { type: 'string', source: 'users.nickname' }, + }); + }); + it('should infer fields', async () => { const model = class extends SQLModel {}; model.init(null, { @@ -60,4 +75,23 @@ left join roles r on ru.role_name=r.name`; name: { type: 'string', source: 'roles.name' }, }); }); + + it('should infer fields for with statement', async () => { + const model = class extends SQLModel {}; + model.init(null, { + modelName: 'test', + tableName: 'test', + sequelize: db.sequelize, + }); + model.database = db; + model.sql = `with u as (select id, nickname from users), + r as (select id, title, name from roles) + select u.id as uid, u.nickname, r.title, r.name`; + expect(model.inferFields()).toMatchObject({ + uid: { type: 'bigInt', source: 'users.id' }, + nickname: { type: 'string', source: 'users.nickname' }, + title: { type: 'string', source: 'roles.title' }, + name: { type: 'string', source: 'roles.name' }, + }); + }); }); diff --git a/packages/core/database/src/__tests__/sql-collection/select-query.test.ts b/packages/core/database/src/__tests__/sql-collection/select-query.test.ts index 4bac0122f1..497e07c7b6 100644 --- a/packages/core/database/src/__tests__/sql-collection/select-query.test.ts +++ b/packages/core/database/src/__tests__/sql-collection/select-query.test.ts @@ -37,8 +37,10 @@ describe('select query', () => { }); test('group', () => { - const query = queryGenerator.selectQuery('users', { group: 'id' }, model); - expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" GROUP BY "id";'); + const query1 = queryGenerator.selectQuery('users', { group: 'id' }, model); + expect(query1).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" GROUP BY "id";'); + const query2 = queryGenerator.selectQuery('users', { group: ['id', 'name'] }, model); + expect(query2).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" GROUP BY "id", "name";'); }); test('order', () => { diff --git a/packages/core/database/src/__tests__/sql-collection/sql-collection.test.ts b/packages/core/database/src/__tests__/sql-collection/sql-collection.test.ts new file mode 100644 index 0000000000..658df0c6db --- /dev/null +++ b/packages/core/database/src/__tests__/sql-collection/sql-collection.test.ts @@ -0,0 +1,20 @@ +import { mockDatabase } from '../../mock-database'; +import { SqlCollection } from '../sql-collection'; + +test('sql-collection', async () => { + const db = mockDatabase({ tablePrefix: '' }); + await db.clean({ drop: true }); + const collection = db.collectionFactory.createCollection({ + name: 'test', + sql: true, + }); + expect(collection.isSql()).toBe(true); + expect(collection.collectionSchema()).toBeUndefined(); + expect(collection.options.autoGenId).toBe(false); + expect(collection.options.timestamps).toBe(false); + expect(collection.options.underscored).toBe(false); + + collection.modelInit(); + // @ts-ignore + expect(collection.model._schema).toBeUndefined(); +}); diff --git a/packages/core/database/src/sql-collection/sql-collection.ts b/packages/core/database/src/sql-collection/sql-collection.ts index 80a395d3cf..9330e1bf3e 100644 --- a/packages/core/database/src/sql-collection/sql-collection.ts +++ b/packages/core/database/src/sql-collection/sql-collection.ts @@ -18,6 +18,7 @@ export class SqlCollection extends Collection { return undefined; } + /* istanbul ignore next -- @preserve */ get filterTargetKey() { const targetKey = this.options?.filterTargetKey || 'id'; if (targetKey && this.model.getAttributes()[targetKey]) { diff --git a/packages/core/database/src/sql-collection/sql-model.ts b/packages/core/database/src/sql-collection/sql-model.ts index c99da68d24..bc9c5a95f1 100644 --- a/packages/core/database/src/sql-collection/sql-model.ts +++ b/packages/core/database/src/sql-collection/sql-model.ts @@ -36,26 +36,38 @@ export class SQLModel extends Model { if (Array.isArray(ast)) { ast = ast[0]; } + ast.from = ast.from || []; + ast.columns = ast.columns || []; if (ast.with) { + ast.with.forEach((withItem: any) => { + const as = withItem.name; + const withAst = withItem.stmt.ast; + ast.from.push(...withAst.from.map((f: any) => ({ ...f, as }))); + ast.columns.push( + ...withAst.columns.map((c: any) => ({ + ...c, + expr: { + ...c.expr, + table: as, + }, + })), + ); + }); + } + if (ast.columns === '*') { const tables = new Set(); - // parse sql includes with clause is not accurate - // the parsed columns seems to be always '*' - // it is supposed to be improved in the future - ast.with.forEach((withItem: { tableList: string[] }) => { - const tableList = withItem.tableList; - tableList.forEach((table) => { - const name = table.split('::')[2]; // "select::null::users" - tables.add(name); - }); + ast.from.forEach((fromItem: { table: string; as: string }) => { + tables.add(fromItem.table); }); return Array.from(tables).map((table) => ({ table, columns: '*' })); } - if (ast.columns === '*') { - return ast.from.map((fromItem: { table: string; as: string }) => ({ - table: fromItem.table, - columns: '*', - })); - } + const tableAliases = {}; + ast.from.forEach((fromItem: { table: string; as: string }) => { + if (!fromItem.as) { + return; + } + tableAliases[fromItem.as] = fromItem.table; + }); const columns: string[] = ast.columns.reduce( ( tableMp: { [table: string]: { name: string; as: string }[] }, @@ -72,22 +84,17 @@ export class SQLModel extends Model { return tableMp; } const table = column.expr.table; + const name = tableAliases[table] || table; const columnAttr = { name: column.expr.column, as: column.as }; - if (!tableMp[table]) { - tableMp[table] = [columnAttr]; + if (!tableMp[name]) { + tableMp[name] = [columnAttr]; } else { - tableMp[table].push(columnAttr); + tableMp[name].push(columnAttr); } return tableMp; }, {}, ); - ast.from.forEach((fromItem: { table: string; as: string }) => { - if (columns[fromItem.as]) { - columns[fromItem.table] = columns[fromItem.as]; - columns[fromItem.as] = undefined; - } - }); return Object.entries(columns) .filter(([_, columns]) => columns) .map(([table, columns]) => ({ table, columns })); diff --git a/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json index 3962ce6fd7..1386165bf1 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json @@ -16,6 +16,7 @@ "Please enter a valid username": "Please enter a valid username", "Please enter a valid email": "Please enter a valid email", "Please enter your username or email": "Please enter your username or email", + "Please enter a password": "Please enter a password", "SMS": "SMS", "Username/Email": "Username/Email", "Auth UID": "Auth UID", diff --git a/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json index ac0540f442..aeb0d18021 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json @@ -16,6 +16,7 @@ "Please enter a valid username": "请输入有效的用户名", "Please enter a valid email": "请输入有效的邮箱", "Please enter your username or email": "请输入用户名或邮箱", + "Please enter a password": "请输入密码", "SMS": "短信", "Username/Email": "用户名/邮箱", "Auth UID": "认证标识", diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/actions.test.ts b/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/actions.test.ts index ae8cf0d4d3..3d5a2e28c3 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/actions.test.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/actions.test.ts @@ -97,6 +97,29 @@ describe('actions', () => { await app.destroy(); }); + it('should check parameters when signing in', async () => { + const res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({}); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('Please enter your username or email'); + }); + + it('should check user when signing in', async () => { + const res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({ + email: 'no-exists@nocobase.com', + }); + expect(res.statusCode).toEqual(401); + expect(res.error.text).toBe('The username or email is incorrect, please re-enter'); + }); + + it('should check password when signing in', async () => { + const res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({ + email: process.env.INIT_ROOT_EMAIL, + password: 'incorrect', + }); + expect(res.statusCode).toEqual(401); + expect(res.error.text).toBe('The password is incorrect, please re-enter'); + }); + it('should sign in with password', async () => { let res = await agent.resource('auth').check(); expect(res.body.data.id).toBeUndefined(); @@ -175,35 +198,72 @@ describe('actions', () => { password: '12345', }, }); - const res = await agent.login(user).post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ + const userAgent = await agent.login(user); + + // Should check password consistency + const res = await userAgent.post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ oldPassword: '12345', newPassword: '123456', + confirmPassword: '1234567', + }); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('The password is inconsistent, please re-enter'); + + // Should check old password + const res1 = await userAgent.post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ + oldPassword: '1', + newPassword: '123456', confirmPassword: '123456', }); - expect(res.statusCode).toEqual(200); + expect(res1.statusCode).toEqual(401); + expect(res1.error.text).toBe('The password is incorrect, please re-enter'); - // Create a user without username - const user1 = await userRepo.create({ - values: { - username: 'test2', - password: '12345', - }, - }); - const res2 = await agent.login(user1).post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ + const res2 = await userAgent.post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ oldPassword: '12345', newPassword: '123456', confirmPassword: '123456', }); expect(res2.statusCode).toEqual(200); + + // Create a user without username + const user1 = await userRepo.create({ + values: { + email: 'test3@nocobase.com', + password: '12345', + }, + }); + const res3 = await agent.login(user1).post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({ + oldPassword: '12345', + newPassword: '123456', + confirmPassword: '123456', + }); + expect(res3.statusCode).toEqual(200); }); - it('should check confirm password', async () => { + it('should check confirm password when signing up', async () => { const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ username: 'new', password: 'new', confirm_password: 'new1', }); expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('The password is inconsistent, please re-enter'); + }); + + it('should check username when signing up', async () => { + const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: '@@', + }); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('Please enter a valid username'); + }); + + it('should check password when signing up', async () => { + const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: 'new', + }); + expect(res.statusCode).toEqual(400); + expect(res.error.text).toBe('Please enter a password'); }); }); }); diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/auth-model.test.ts b/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/auth-model.test.ts new file mode 100644 index 0000000000..1796c6a4e5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/auth-model.test.ts @@ -0,0 +1,105 @@ +import { MockServer, createMockServer } from '@nocobase/test'; +import { AuthModel } from '../model/authenticator'; +import { Database, Repository } from '@nocobase/database'; + +describe('AuthModel', () => { + let app: MockServer; + let db: Database; + let repo: Repository; + + beforeEach(async () => { + app = await createMockServer({ + plugins: ['auth', 'users'], + }); + db = app.db; + repo = db.getRepository('authenticators'); + }); + + afterEach(async () => { + await app.db.clean({ drop: true }); + await app.destroy(); + }); + + it('should new user', async () => { + const emitSpy = vi.spyOn(db, 'emitAsync'); + const authenticator = (await repo.create({ + values: { + name: 'test', + authType: 'testType', + }, + })) as AuthModel; + const user = await authenticator.newUser('uuid1', { + username: 'test', + }); + expect(emitSpy).toHaveBeenCalledWith('users.afterCreateWithAssociations', user, expect.any(Object)); + const res = await repo.findOne({ + filterByTk: authenticator.id, + appends: ['users'], + }); + expect(res.users.length).toBe(1); + expect(res.users[0].username).toBe('test'); + }); + + it('should new user without userValues', async () => { + const authenticator = (await repo.create({ + values: { + name: 'test', + authType: 'testType', + }, + })) as AuthModel; + await authenticator.newUser('uuid1'); + const res = await repo.findOne({ + filterByTk: authenticator.id, + appends: ['users'], + }); + expect(res.users.length).toBe(1); + expect(res.users[0].nickname).toBe('uuid1'); + }); + + it('should find user', async () => { + const authenticator = (await repo.create({ + values: { + name: 'test', + authType: 'testType', + }, + })) as AuthModel; + const user = await authenticator.newUser('uuid1', { + username: 'test', + }); + const res = await authenticator.findUser('uuid1'); + expect(res).toBeDefined(); + expect(res.id).toBe(user.id); + }); + + it('should find or create user', async () => { + const authenticator = (await repo.create({ + values: { + name: 'test', + authType: 'testType', + }, + })) as AuthModel; + // find user + let user1 = await authenticator.findUser('uuid1'); + expect(user1).toBeUndefined(); + user1 = await authenticator.newUser('uuid1', { + username: 'test', + }); + const res1 = await authenticator.findOrCreateUser('uuid1', { + username: 'test1', + }); + expect(res1).toBeDefined(); + expect(res1.username).toBe('test'); + expect(res1.id).toBe(user1.id); + + // create user + let user2 = await authenticator.findUser('uuid2'); + expect(user2).toBeUndefined(); + user2 = await authenticator.findOrCreateUser('uuid2', { + username: 'test2', + }); + const res2 = await authenticator.findUser('uuid2'); + expect(res2).toBeDefined(); + expect(res2.username).toBe('test2'); + expect(res2.id).toBe(user2.id); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/actions/auth.ts b/packages/plugins/@nocobase/plugin-auth/src/server/actions/auth.ts index ed204a5d93..c84fe9e79a 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/actions/auth.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/actions/auth.ts @@ -1,3 +1,4 @@ +/* istanbul ignore file -- @preserve */ import { Context, Next } from '@nocobase/actions'; export default { diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts b/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts index 053e205618..df19b01aa1 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts @@ -12,12 +12,10 @@ export class BasicAuth extends BaseAuth { async validate() { const ctx = this.ctx; const { - values: { - account, // Username or email - email, // Old parameter, compatible with old api - password, - }, - } = ctx.action.params; + account, // Username or email + email, // Old parameter, compatible with old api + password, + } = ctx.action.params.values || {}; if (!account && !email) { ctx.throw(400, ctx.t('Please enter your username or email', { ns: namespace })); @@ -55,6 +53,9 @@ export class BasicAuth extends BaseAuth { if (!/^[^@.<>"'/]{2,16}$/.test(username)) { ctx.throw(400, ctx.t('Please enter a valid username', { ns: namespace })); } + if (!password) { + ctx.throw(400, ctx.t('Please enter a password', { ns: namespace })); + } if (password !== confirm_password) { ctx.throw(400, ctx.t('The password is inconsistent, please re-enter', { ns: namespace })); } @@ -62,6 +63,7 @@ export class BasicAuth extends BaseAuth { return user; } + /* istanbul ignore next -- @preserve */ async lostPassword() { const ctx = this.ctx; const { @@ -83,6 +85,7 @@ export class BasicAuth extends BaseAuth { return user; } + /* istanbul ignore next -- @preserve */ async resetPassword() { const ctx = this.ctx; const { @@ -104,6 +107,7 @@ export class BasicAuth extends BaseAuth { return user; } + /* istanbul ignore next -- @preserve */ async getUserByResetToken() { const ctx = this.ctx; const { token } = ctx.action.params; @@ -121,8 +125,11 @@ export class BasicAuth extends BaseAuth { async changePassword() { const ctx = this.ctx; const { - values: { oldPassword, newPassword }, + values: { oldPassword, newPassword, confirmPassword }, } = ctx.action.params; + if (newPassword !== confirmPassword) { + ctx.throw(400, ctx.t('The password is inconsistent, please re-enter', { ns: namespace })); + } const currentUser = ctx.auth.user; if (!currentUser) { ctx.throw(401); diff --git a/packages/plugins/@nocobase/plugin-cas/src/server/__tests__/cas.test.ts b/packages/plugins/@nocobase/plugin-cas/src/server/__tests__/cas.test.ts new file mode 100644 index 0000000000..49183e2596 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-cas/src/server/__tests__/cas.test.ts @@ -0,0 +1,160 @@ +import { Database } from '@nocobase/database'; +import { MockServer, createMockServer } from '@nocobase/test'; +import { vi } from 'vitest'; +import { authType } from '../../constants'; +import axios from 'axios'; +import { CASAuth } from '../auth'; +import { AppSupervisor } from '@nocobase/server'; + +describe('cas', () => { + let app: MockServer; + let db: Database; + let agent; + let authenticator; + + beforeAll(async () => { + app = await createMockServer({ + plugins: ['users', 'auth', 'cas'], + }); + db = app.db; + agent = app.agent(); + + const authenticatorRepo = db.getRepository('authenticators'); + authenticator = await authenticatorRepo.create({ + values: { + name: 'cas-auth', + authType: authType, + enabled: 1, + options: { + casUrl: 'http://localhost:3000/cas', + serviceDomain: 'http://localhost:13000', + }, + }, + }); + }); + + afterAll(async () => { + await app.destroy(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await db.getRepository('users').destroy({ + truncate: true, + }); + }); + + it('should not sign in without auto signup', async () => { + await authenticator.update({ + options: { + ...authenticator.options, + autoSignup: false, + }, + }); + vi.spyOn(axios, 'get').mockResolvedValue({ + data: ` + + + test-user + PGTIOU-84678-8a9d... + + + +`, + }); + + const res = await agent.set('X-Authenticator', 'cas-auth').get('/auth:signIn?ticket=test-ticket'); + + expect(res.statusCode).toBe(401); + expect(res.text).toBe('User not found'); + }); + + it('should sign in with auto signup', async () => { + await authenticator.update({ + options: { + ...authenticator.options, + autoSignup: true, + }, + }); + vi.spyOn(axios, 'get').mockResolvedValue({ + data: ` + + + test-user + PGTIOU-84678-8a9d... + + + +`, + }); + + const res = await agent.set('X-Authenticator', 'cas-auth').get('/auth:signIn?ticket=test-ticket'); + + expect(res.statusCode).toBe(200); + expect(res.body.data.user).toBeDefined(); + expect(res.body.data.user.nickname).toBe('test-user'); + }); + + it('missing ticket', async () => { + const res = await agent.set('X-Authenticator', 'cas-auth').get('/auth:signIn'); + expect(res.statusCode).toBe(401); + expect(res.text).toBe('Missing ticket'); + }); + + it('invalid ticket', async () => { + vi.spyOn(axios, 'get').mockResolvedValue({ + data: ` + + + Ticket ST-1856339-aA5Yuvrxzpv8Tau1cYQ7 not recognized + + +`, + }); + + const res = await agent.set('X-Authenticator', 'cas-auth').get('/auth:signIn?ticket=test-ticket'); + expect(res.statusCode).toBe(401); + expect(res.text).toBe('Invalid ticket'); + }); + + it('cas:login', async () => { + const res = await agent.get('/cas:login?authenticator=cas-auth'); + expect(res.statusCode).toBe(302); + const service = encodeURIComponent( + `http://localhost:13000${process.env.API_BASE_URL || '/api/'}cas:service?authenticator=cas-auth&__appName=${ + app.name + }&redirect=/admin`, + ); + expect(res.headers.location).toBe(`http://localhost:3000/cas/login?service=${service}`); + }); + + it('cas:service', async () => { + vi.spyOn(CASAuth.prototype, 'signIn').mockResolvedValue({ + user: {} as any, + token: 'test-token', + }); + const res = await agent.get('/cas:service?authenticator=cas-auth&__appName=main'); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/admin?authenticator=cas-auth&token=test-token`); + }); + + it('cas:service, sub app', async () => { + vi.spyOn(CASAuth.prototype, 'signIn').mockResolvedValue({ + user: {} as any, + token: 'test-token', + }); + vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({ + runningMode: 'multiple', + } as any); + const res = await agent.get('/cas:service?authenticator=cas-auth&__appName=sub'); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/apps/sub/admin?authenticator=cas-auth&token=test-token`); + }); + + it('cas:service, error', async () => { + vi.spyOn(CASAuth.prototype, 'signIn').mockRejectedValue(new Error('test error')); + const res = await agent.get('/cas:service?authenticator=cas-auth&__appName=main'); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/signin?authenticator=cas-auth&error=test%20error&redirect=/admin`); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-cas/src/server/actions/service.ts b/packages/plugins/@nocobase/plugin-cas/src/server/actions/service.ts index ae811ddb91..27bc88568f 100644 --- a/packages/plugins/@nocobase/plugin-cas/src/server/actions/service.ts +++ b/packages/plugins/@nocobase/plugin-cas/src/server/actions/service.ts @@ -23,7 +23,9 @@ export const service = async (ctx: Context, next: Next) => { const { token } = await auth.signIn(); ctx.redirect(`${prefix}${redirect || '/admin'}?authenticator=${authenticator}&token=${token}`); } catch (error) { - ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&error=${error.message}&redirect=${redirect}`); + ctx.redirect( + `${prefix}/signin?authenticator=${authenticator}&error=${error.message}&redirect=${redirect || '/admin'}`, + ); } return next(); }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/formatter.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/formatter.test.ts index 35bcb7907b..7c96f75042 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/formatter.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/formatter.test.ts @@ -1,50 +1,47 @@ -import { vi } from 'vitest'; -import { dateFormatFn } from '../actions/formatter'; +import { formatter } from '../actions/formatter'; describe('formatter', () => { const field = 'field'; const format = 'YYYY-MM-DD hh:mm:ss'; - describe('dateFormatFn', () => { - it('should return correct format for sqlite', () => { - const sequelize = { - fn: vi.fn().mockImplementation((fn: string, format: string, field: string) => ({ - fn, - format, - field, - })), - col: vi.fn().mockImplementation((field: string) => field), - }; - const dialect = 'sqlite'; - const result = dateFormatFn(sequelize, dialect, field, format); - expect(result.format).toEqual('%Y-%m-%d %H:%M:%S'); - }); + it('should return correct format for sqlite', () => { + const sequelize = { + fn: (fn: string, format: string, field: string) => ({ + fn, + format, + field, + }), + col: (field: string) => field, + getDialect: () => 'sqlite', + }; + const result = formatter(sequelize, 'datetime', field, format); + expect(result.format).toEqual('%Y-%m-%d %H:%M:%S'); + }); - it('should return correct format for mysql', () => { - const sequelize = { - fn: vi.fn().mockImplementation((fn: string, field: string, format: string) => ({ - fn, - format, - field, - })), - col: vi.fn().mockImplementation((field: string) => field), - }; - const dialect = 'mysql'; - const result = dateFormatFn(sequelize, dialect, field, format); - expect(result.format).toEqual('%Y-%m-%d %H:%i:%S'); - }); + it('should return correct format for mysql', () => { + const sequelize = { + fn: (fn: string, field: string, format: string) => ({ + fn, + format, + field, + }), + col: (field: string) => field, + getDialect: () => 'mysql', + }; + const result = formatter(sequelize, 'datetime', field, format); + expect(result.format).toEqual('%Y-%m-%d %H:%i:%S'); + }); - it('should return correct format for postgres', () => { - const sequelize = { - fn: vi.fn().mockImplementation((fn: string, field: string, format: string) => ({ - fn, - format, - field, - })), - col: vi.fn().mockImplementation((field: string) => field), - }; - const dialect = 'postgres'; - const result = dateFormatFn(sequelize, dialect, field, format); - expect(result.format).toEqual('YYYY-MM-DD HH24:MI:SS'); - }); + it('should return correct format for postgres', () => { + const sequelize = { + fn: (fn: string, field: string, format: string) => ({ + fn, + format, + field, + }), + col: (field: string) => field, + getDialect: () => 'postgres', + }; + const result = formatter(sequelize, 'datetime', field, format); + expect(result.format).toEqual('YYYY-MM-DD HH24:MI:SS'); }); }); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts index d6419884a8..c2f7a5509a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts @@ -1,7 +1,14 @@ import { MockServer, createMockServer } from '@nocobase/test'; import compose from 'koa-compose'; import { vi } from 'vitest'; -import { cacheMiddleware, parseBuilder, parseFieldAndAssociations } from '../actions/query'; +import { + cacheMiddleware, + parseBuilder, + parseFieldAndAssociations, + checkPermission, + postProcess, + parseVariables, +} from '../actions/query'; const formatter = await import('../actions/formatter'); describe('query', () => { @@ -14,7 +21,7 @@ describe('query', () => { let app: MockServer; beforeAll(async () => { app = await createMockServer({ - plugins: ['data-source-manager'], + plugins: ['data-source-manager', 'users', 'acl'], }); app.db.options.underscored = true; app.db.collection({ @@ -41,32 +48,32 @@ describe('query', () => { }, ], }); - app.db.collection({ - name: 'users', - fields: [ - { - name: 'id', - type: 'bigInt', - }, - { - name: 'name', - type: 'string', - }, - ], - }); ctx = { app, - db: { - sequelize, - getRepository: (name: string) => app.db.getRepository(name), - getModel: (name: string) => app.db.getModel(name), - getCollection: (name: string) => app.db.getCollection(name), - options: { - underscored: true, + db: app.db, + }; + ctx.db.sequelize = sequelize; + }); + + it('should check permissions', async () => { + const context = { + ...ctx, + state: { + currentRole: '', + }, + action: { + params: { + values: { + collection: 'users', + }, }, }, + throw: vi.fn(), }; + await checkPermission(context, async () => {}); + expect(context.throw).toBeCalledWith(403, 'No permissions'); }); + it('should parse field and associations', async () => { const context = { ...ctx, @@ -225,7 +232,65 @@ describe('query', () => { await compose([parseFieldAndAssociations, parseBuilder])(context, async () => {}); expect(context.action.params.values.queryParams.where.createdAt).toBeDefined(); }); + + it('post process', async () => { + const context = { + ...ctx, + action: { + params: { + values: { + data: [{ key: '123' }], + fieldMap: { + key: { type: 'bigInt' }, + }, + }, + }, + }, + }; + await postProcess(context, async () => {}); + expect(context.body).toEqual([{ key: 123 }]); + }); + + it('parse variables', async () => { + const context = { + ...ctx, + state: { + currentUser: { + id: 1, + }, + }, + get: (key: string) => { + return { + 'x-timezone': '', + }[key]; + }, + action: { + params: { + values: { + filter: { + $and: [ + { + createdAt: { $dateOn: '{{$nDate.now}}' }, + }, + { + userId: { $eq: '{{$user.id}}' }, + }, + ], + }, + }, + }, + }, + }; + await parseVariables(context, async () => {}); + const { filter } = context.action.params.values; + const dateOn = filter.$and[0].createdAt.$dateOn; + console.log(dateOn); + expect(new Date(dateOn).getTime()).toBeLessThanOrEqual(new Date().getTime()); + const userId = filter.$and[1].userId.$eq; + expect(userId).toBe(1); + }); }); + describe('cacheMiddleware', () => { const key = 'test-key'; const value = 'test-val'; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/formatter.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/formatter.ts index 2fe4b6598a..6ed732e1b7 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/formatter.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/formatter.ts @@ -27,6 +27,7 @@ export const dateFormatFn = (sequelize: any, dialect: string, field: string, for } }; +/* istanbul ignore next -- @preserve */ export const formatFn = (sequelize: any, dialect: string, field: string, format: string) => { switch (dialect) { case 'sqlite': diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts index 3c030c2783..b2ef4392f7 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts @@ -316,7 +316,7 @@ export const cacheMiddleware = async (ctx: Context, next: Next) => { } }; -const checkPermission = (ctx: Context, next: Next) => { +export const checkPermission = (ctx: Context, next: Next) => { const { collection } = ctx.action.params.values as QueryParams; const roleName = ctx.state.currentRole || 'anonymous'; const can = ctx.app.acl.can({ role: roleName, resource: collection, action: 'list' }); diff --git a/packages/plugins/@nocobase/plugin-localization-management/src/client/Localization.tsx b/packages/plugins/@nocobase/plugin-localization-management/src/client/Localization.tsx index 88c6881484..e255914768 100644 --- a/packages/plugins/@nocobase/plugin-localization-management/src/client/Localization.tsx +++ b/packages/plugins/@nocobase/plugin-localization-management/src/client/Localization.tsx @@ -151,7 +151,7 @@ const Sync = () => { setLoading(true); await api.resource('localization').sync({ values: { - type: checkedList, + types: checkedList, }, }); setLoading(false); diff --git a/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/middleware.test.ts b/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/middleware.test.ts new file mode 100644 index 0000000000..afc30934aa --- /dev/null +++ b/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/middleware.test.ts @@ -0,0 +1,34 @@ +import { MockServer, createMockServer } from '@nocobase/test'; +import PluginLocalizationServer from '../plugin'; + +describe('middleware', () => { + let app: MockServer; + let agent: any; + let plugin: PluginLocalizationServer; + + beforeEach(async () => { + app = await createMockServer({ + plugins: ['localization-management', 'client', 'ui-schema-storage', 'system-settings'], + }); + await app.localeManager.load(); + agent = app.agent(); + plugin = app.pm.get('localization-management') as PluginLocalizationServer; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should merge resources', async () => { + vi.spyOn(plugin.resources, 'getResources').mockResolvedValue({ + 'test-resource': { + 'test-text': 'text Translation', + }, + }); + const res = await agent.resource('app').getLang(); + const data = JSON.parse(res.text); + const resources = data.data.resources; + expect(resources['test-resource']).toBeDefined(); + expect(resources['test-resource']['test-text']).toBe('text Translation'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/sync.test.ts b/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/sync.test.ts new file mode 100644 index 0000000000..24d5a84207 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/sync.test.ts @@ -0,0 +1,157 @@ +import { Model, Repository } from '@nocobase/database'; +import { MockServer, createMockServer } from '@nocobase/test'; +import { getSchemaUid, getTextsFromDB, getTextsFromMenu } from '../actions/localization'; +import { getMenuSchema, getMobileMenuSchema } from './utils'; +import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage'; + +describe('sync', () => { + let app: MockServer; + let agent: any; + let repo: Repository; + + beforeEach(async () => { + app = await createMockServer({ + plugins: [ + // 'data-source-manager', + 'collection-manager', + 'localization-management', + 'ui-schema-storage', + 'client', + 'mobile-client', + 'error-handler', + ], + }); + await app.localeManager.load(); + agent = app.agent(); + repo = app.db.getRepository('localizationTexts'); + }); + + afterEach(async () => { + await app.db.clean({ drop: true }); + await app.destroy(); + }); + + it('should check sync types', async () => { + const res = await agent.resource('localization').sync({ + types: [], + }); + expect(res.status).toBe(400); + expect(res.body.errors[0].message).toBe('Please provide synchronization source.'); + }); + + it('should sync local resources', async () => { + vi.spyOn(app.localeManager, 'getResources').mockResolvedValue({ + test: { + 'sync.local.test1': 'Test1', + 'sync.local.test2': 'Test2', + }, + }); + const res = await agent.resource('localization').sync({ + values: { + types: ['local'], + }, + }); + expect(res.status).toBe(200); + const texts = await repo.find({ + filter: { + text: { + $in: ['sync.local.test1', 'sync.local.test2'], + }, + module: 'resources.test', + 'translations.locale': 'en-US', + }, + appends: ['translations'], + }); + expect(texts.length).toBe(2); + expect(texts[0].translations[0].translation).toBe('Test1'); + expect(texts[1].translations[0].translation).toBe('Test2'); + }); + + it('should get texts from menu', async () => { + const { adminSchemaUid, mobileSchemaUid } = await getSchemaUid(app.db); + const repo = app.db.getRepository('uiSchemas') as UiSchemaRepository; + await repo.insertAdjacent('beforeEnd', adminSchemaUid, getMenuSchema('test')); + await repo.insertAdjacent('beforeEnd', mobileSchemaUid, getMobileMenuSchema('test-mobile')); + const result = await getTextsFromMenu(app.db); + expect(Object.keys(result)).toEqual(['test', 'test-mobile']); + }); + + it('should get texts from db', async () => { + await app.db.getRepository('collections').create({ + values: { + key: 'test-collection', + name: 'sync.db.collection', + title: 'sync.db.collection', + }, + hooks: false, + }); + await app.db.getRepository('fields').create({ + values: { + key: 'test-field', + name: 'sync.db.field', + title: 'sync.db.field', + collectionNname: 'sync.db.collection', + options: { + uiSchema: { + title: '{{t("sync.db.field")}}', + enum: [{ label: 'sync.db.enum', value: '1' }], + }, + }, + }, + hooks: false, + }); + const result = await getTextsFromDB(app.db); + expect(Object.keys(result)).toMatchObject(['sync.db.collection', 'sync.db.field', 'sync.db.enum']); + }); + + it('should add text when adding menu item', async () => { + vi.spyOn(repo, 'create'); + const title = 'sync.menu.hook'; + const text = await repo.findOne({ + filter: { + text: title, + }, + }); + expect(text).toBeNull(); + const { adminSchemaUid } = await getSchemaUid(app.db); + const schemaRepo = app.db.getRepository('uiSchemas') as UiSchemaRepository; + await schemaRepo.insertAdjacent('beforeEnd', adminSchemaUid, getMenuSchema(title, true)); + expect(repo.create).toBeCalledWith({ + values: { + module: 'resources.lm-menus', + text: title, + }, + }); + }); + + it('should add text when creating fields', async () => { + const model = app.db.getModel('localizationTexts'); + vi.spyOn(model, 'bulkCreate'); + await app.db.getRepository('fields').create({ + values: { + name: 'sync.db.field', + title: 'sync.db.field', + collectionNname: 'sync.db.collection', + options: { + uiSchema: { + title: '{{t("sync.db.field")}}', + enum: [{ label: 'sync.db.enum', value: '1' }], + }, + }, + }, + }); + expect(model.bulkCreate).toBeCalledWith( + [ + { + module: 'resources.lm-collections', + text: 'sync.db.field', + }, + { + module: 'resources.lm-collections', + text: 'sync.db.enum', + }, + ], + { transaction: expect.anything() }, + ); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/utils.ts b/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/utils.ts new file mode 100644 index 0000000000..1bf057f07d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-localization-management/src/server/__tests__/utils.ts @@ -0,0 +1,84 @@ +export const getMenuSchema = (title: string, hook = false) => ({ + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + title, + 'x-component': 'Menu.Item', + 'x-decorator': 'ACLMenuItemProvider', + 'x-component-props': {}, + ...(hook + ? { + 'x-server-hooks': [ + { + type: 'onSelfSave', + method: 'extractTextToLocale', + }, + ], + } + : {}), + properties: { + page: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Page', + 'x-async': true, + properties: { + '8x6xrx59vpd': { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'page:addBlock', + 'x-uid': 'th3q97bzylq', + name: '8x6xrx59vpd', + 'x-app-version': '0.21.0-alpha.7', + }, + }, + 'x-uid': 'gqgfjv38all', + name: 'page', + 'x-app-version': '0.21.0-alpha.7', + }, + }, + name: '6ler5jumdz0', + 'x-uid': 'ri757idkdw0', + 'x-app-version': '0.21.0-alpha.7', +}); + +export const getMobileMenuSchema = (title: string, hook = false) => ({ + name: 'tabBar', + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'MTabBar', + 'x-component-props': {}, + properties: { + '9b6f369x1ef': { + 'x-uid': '0lafotn74pu', + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'MTabBar.Item', + 'x-designer': 'MTabBar.Item.Designer', + 'x-component-props': { + icon: 'HomeOutlined', + title, + }, + ...(hook + ? { + 'x-server-hooks': [ + { + type: 'onSelfSave', + method: 'extractTextToLocale', + }, + ], + } + : {}), + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'uq0qwy7rkyg', + 'x-async': false, + 'x-index': 1, +}); diff --git a/packages/plugins/@nocobase/plugin-localization-management/src/server/actions/localization.ts b/packages/plugins/@nocobase/plugin-localization-management/src/server/actions/localization.ts index 0d92d67ba9..6ef640f674 100644 --- a/packages/plugins/@nocobase/plugin-localization-management/src/server/actions/localization.ts +++ b/packages/plugins/@nocobase/plugin-localization-management/src/server/actions/localization.ts @@ -3,7 +3,7 @@ import { Database, Model, Op } from '@nocobase/database'; import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage'; import { NAMESPACE_COLLECTIONS, NAMESPACE_MENUS } from '../constans'; import LocalizationManagementPlugin from '../plugin'; -import { getTextsFromDBRecord, getTextsFromUISchema } from '../utils'; +import { getTextsFromDBRecord } from '../utils'; const getResourcesInstance = async (ctx: Context) => { const plugin = ctx.app.getPlugin('localization-management') as LocalizationManagementPlugin; @@ -58,16 +58,6 @@ export const getUISchemas = async (db: Database) => { return uiSchemas; }; -const getTextsFromUISchemas = async (db: Database) => { - const result = {}; - const schemas = await getUISchemas(db); - schemas.forEach((schema: Model) => { - const texts = getTextsFromUISchema(schema.schema); - texts.forEach((text) => (result[text] = '')); - }); - return result; -}; - export const getTextsFromDB = async (db: Database) => { const result = {}; const collections = Array.from(db.collections.values()); @@ -88,7 +78,7 @@ export const getTextsFromDB = async (db: Database) => { return result; }; -const getSchemaUid = async (db: Database, migrate = false) => { +export const getSchemaUid = async (db: Database, migrate = false) => { if (migrate) { const systemSettings = await db.getRepository('systemSettings').findOne(); const options = systemSettings?.options || {}; @@ -135,22 +125,22 @@ const sync = async (ctx: Context, next: Next) => { ctx.logger.info('Start sync localization resources'); const resourcesInstance = await getResourcesInstance(ctx); const locale = ctx.get('X-Locale') || 'en-US'; - const { type = [] } = ctx.action.params.values || {}; - if (!type.length) { + const { types = [] } = ctx.action.params.values || {}; + if (!types.length) { ctx.throw(400, ctx.t('Please provide synchronization source.')); } let resources: { [module: string]: any } = { client: {} }; - if (type.includes('local')) { + if (types.includes('local')) { resources = await getResources(ctx); } - if (type.includes('menu')) { + if (types.includes('menu')) { const menuTexts = await getTextsFromMenu(ctx.db); resources[NAMESPACE_MENUS] = { ...menuTexts, }; } - if (type.includes('db')) { + if (types.includes('db')) { const dbTexts = await getTextsFromDB(ctx.db); resources[NAMESPACE_COLLECTIONS] = { ...dbTexts, diff --git a/packages/plugins/@nocobase/plugin-localization-management/src/server/utils.ts b/packages/plugins/@nocobase/plugin-localization-management/src/server/utils.ts index 1513144940..47d86f4c38 100644 --- a/packages/plugins/@nocobase/plugin-localization-management/src/server/utils.ts +++ b/packages/plugins/@nocobase/plugin-localization-management/src/server/utils.ts @@ -1,5 +1,6 @@ export const compile = (title: string) => (title || '').replace(/{{\s*t\(["|'|`](.*)["|'|`]\)\s*}}/g, '$1'); +/* istanbul ignore next -- @preserve */ export const getTextsFromUISchema = (schema: any) => { const texts = []; const title = compile(schema.title); diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts index 3fec2e75b4..92a0a404ce 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts +++ b/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts @@ -3,6 +3,7 @@ import { MockServer, createMockServer } from '@nocobase/test'; import { vi } from 'vitest'; import { authType } from '../../constants'; import { OIDCAuth } from '../oidc-auth'; +import { AppSupervisor } from '@nocobase/server'; describe('oidc', () => { let app: MockServer; @@ -187,9 +188,39 @@ describe('oidc', () => { expect(res.body.data.user).toBeDefined(); expect(res.body.data.user.id).toBe(user.id); }); + + it('oidc:redirect', async () => { + vi.spyOn(OIDCAuth.prototype, 'signIn').mockResolvedValue({ + user: {} as any, + token: 'test-token', + }); + const res = await agent.get(`/oidc:redirect?state=${encodeURIComponent('name=oidc-auth&app=main')}`); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/admin?authenticator=oidc-auth&token=test-token`); + }); + + it('oidc:redirect, sub app', async () => { + vi.spyOn(OIDCAuth.prototype, 'signIn').mockResolvedValue({ + user: {} as any, + token: 'test-token', + }); + vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({ + runningMode: 'multiple', + } as any); + const res = await agent.get(`/oidc:redirect?state=${encodeURIComponent('name=oidc-auth&app=sub')}`); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/apps/sub/admin?authenticator=oidc-auth&token=test-token`); + }); + + it('oidc:redirect, error', async () => { + vi.spyOn(OIDCAuth.prototype, 'signIn').mockRejectedValue(new Error('test error')); + const res = await agent.get(`/oidc:redirect?state=${encodeURIComponent('name=oidc-auth&app=main')}`); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/signin?redirect=/admin&authenticator=oidc-auth&error=test%20error`); + }); }); -it('field mapping', () => { +test('field mapping', () => { const auth = new OIDCAuth({ authenticator: null, ctx: { @@ -218,3 +249,35 @@ it('field mapping', () => { nickname: 'user1', }); }); + +test('getExchangeBody', () => { + const auth = new OIDCAuth({ + ctx: { + db: { + getCollection: () => ({}), + }, + }, + options: { + oidc: { + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + exchangeBodyKeys: [ + { + paramName: 'client_id', + optionsKey: 'clientId', + enabled: true, + }, + { + paramName: 'client_secret', + optionsKey: 'clientSecret', + enabled: false, + }, + ], + }, + }, + } as any); + const body = auth.getExchangeBody(); + expect(body).toMatchObject({ + client_id: 'test_client_id', + }); +}); diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/plugin.ts index 3c09e38d59..95d0b7c2ca 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-oidc/src/server/plugin.ts @@ -34,6 +34,7 @@ export class PluginOIDCServer extends Plugin { this.app.acl.allow('oidc', '*', 'public'); + /* istanbul ignore next -- @preserve */ Gateway.getInstance().addAppSelectorMiddleware(async (ctx, next) => { const { req } = ctx; const url = new URL(req.url, `http://${req.headers.host}`); diff --git a/packages/plugins/@nocobase/plugin-saml/src/server/__tests__/saml.test.ts b/packages/plugins/@nocobase/plugin-saml/src/server/__tests__/saml.test.ts index 8d7c902ab3..c71f1d6469 100644 --- a/packages/plugins/@nocobase/plugin-saml/src/server/__tests__/saml.test.ts +++ b/packages/plugins/@nocobase/plugin-saml/src/server/__tests__/saml.test.ts @@ -3,6 +3,8 @@ import { MockServer, createMockServer } from '@nocobase/test'; import { SAML } from '@node-saml/node-saml'; import { vi } from 'vitest'; import { authType } from '../../constants'; +import { SAMLAuth } from '../saml-auth'; +import { AppSupervisor } from '@nocobase/server'; describe('saml', () => { let app: MockServer; @@ -204,4 +206,34 @@ describe('saml', () => { expect(res.body.data.user).toBeDefined(); expect(res.body.data.user.id).toBe(user.id); }); + + it('saml:redirect', async () => { + vi.spyOn(SAMLAuth.prototype, 'signIn').mockResolvedValue({ + user: {} as any, + token: 'test-token', + }); + const res = await agent.get('/saml:redirect?authenticator=saml-auth&__appName=main'); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/admin?authenticator=saml-auth&token=test-token`); + }); + + it('saml:redirect, sub app', async () => { + vi.spyOn(SAMLAuth.prototype, 'signIn').mockResolvedValue({ + user: {} as any, + token: 'test-token', + }); + vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({ + runningMode: 'multiple', + } as any); + const res = await agent.get('/saml:redirect?authenticator=saml-auth&__appName=sub'); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/apps/sub/admin?authenticator=saml-auth&token=test-token`); + }); + + it('saml:redirect, error', async () => { + vi.spyOn(SAMLAuth.prototype, 'signIn').mockRejectedValue(new Error('test error')); + const res = await agent.get('/saml:redirect?authenticator=saml-auth&__appName=main'); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`/signin?authenticator=saml-auth&error=test%20error&redirect=/admin`); + }); }); diff --git a/packages/plugins/@nocobase/plugin-saml/src/server/actions/metadata.ts b/packages/plugins/@nocobase/plugin-saml/src/server/actions/metadata.ts deleted file mode 100644 index 1b4f1662f5..0000000000 --- a/packages/plugins/@nocobase/plugin-saml/src/server/actions/metadata.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Context, Next } from '@nocobase/actions'; -import { SAMLAuth } from '../saml-auth'; -import { SAML } from '@node-saml/node-saml'; - -export const metadata = async (ctx: Context, next: Next) => { - const auth = ctx.auth as SAMLAuth; - const options = auth.getOptions(); - const saml = new SAML(options); - - ctx.type = 'text/xml'; - ctx.body = saml.generateServiceProviderMetadata(options.cert as string); - ctx.withoutDataWrapping = true; - - return next(); -}; diff --git a/packages/plugins/@nocobase/plugin-saml/src/server/actions/redirect.ts b/packages/plugins/@nocobase/plugin-saml/src/server/actions/redirect.ts index 8a1573b15e..85043f3e2b 100644 --- a/packages/plugins/@nocobase/plugin-saml/src/server/actions/redirect.ts +++ b/packages/plugins/@nocobase/plugin-saml/src/server/actions/redirect.ts @@ -9,7 +9,7 @@ export const redirect = async (ctx: Context, next: Next) => { if (appName && appName !== 'main') { const appSupervisor = AppSupervisor.getInstance(); if (appSupervisor?.runningMode !== 'single') { - prefix += `/apps/${appName}`; + prefix += `apps/${appName}`; } } const auth = (await ctx.app.authManager.get(authenticator, ctx)) as SAMLAuth; @@ -20,7 +20,9 @@ export const redirect = async (ctx: Context, next: Next) => { const { token } = await auth.signIn(); ctx.redirect(`${prefix}${redirect || '/admin'}?authenticator=${authenticator}&token=${token}`); } catch (error) { - ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&error=${error.message}&redirect=${redirect}`); + ctx.redirect( + `${prefix}/signin?authenticator=${authenticator}&error=${error.message}&redirect=${redirect || '/admin'}`, + ); } await next(); }; diff --git a/packages/plugins/@nocobase/plugin-saml/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-saml/src/server/plugin.ts index c8ebc71db2..7eba6d6f6a 100644 --- a/packages/plugins/@nocobase/plugin-saml/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-saml/src/server/plugin.ts @@ -1,6 +1,5 @@ import { InstallOptions, Plugin } from '@nocobase/server'; import { getAuthUrl } from './actions/getAuthUrl'; -import { metadata } from './actions/metadata'; import { redirect } from './actions/redirect'; import { SAMLAuth } from './saml-auth'; import { authType } from '../constants'; @@ -29,7 +28,6 @@ export class PluginSAMLServer extends Plugin { name: 'saml', actions: { redirect, - metadata, getAuthUrl, }, });