test: add backend unit tests (#4000)

* test: add backend unit tests

* test: cas

* test: oidc & saml

* test: sql collection

* fix: test files

* test: data-visualization

* test: localization

* fix: test
This commit is contained in:
YANG QIA 2024-04-16 17:56:48 +08:00 committed by GitHub
parent 769de9a69e
commit 8b88b29b5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1247 additions and 218 deletions

View File

@ -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<string, any> = 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' });
});
});

View File

@ -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 });
});
});

View File

@ -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');
});
});
});

View File

@ -1,3 +1,4 @@
/* istanbul ignore file -- @preserve */
import { Handlers } from '@nocobase/resourcer';
export const actions = {

View File

@ -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 });
}

View File

@ -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');

View File

@ -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<any> {
return new Promise((resolve, reject) => {
jwt.verify(token, this.secret(), (err: any, decoded: any) => {

View File

@ -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();
});
});

View File

@ -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;

View File

@ -1,3 +1,4 @@
/* istanbul ignore file -- @preserve */
import { RedisStore } from 'cache-manager-redis-yet';
import { BloomFilter } from '.';
import { Cache } from '../cache';

View File

@ -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' },
});
});
});

View File

@ -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', () => {

View File

@ -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<SqlCollection>({
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();
});

View File

@ -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]) {

View File

@ -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<string>();
// 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 }));

View File

@ -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",

View File

@ -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": "认证标识",

View File

@ -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');
});
});
});

View File

@ -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);
});
});

View File

@ -1,3 +1,4 @@
/* istanbul ignore file -- @preserve */
import { Context, Next } from '@nocobase/actions';
export default {

View File

@ -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);

View File

@ -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: `
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>test-user</cas:user>
<cas:proxyGrantingTicket>PGTIOU-84678-8a9d...
</cas:proxyGrantingTicket>
</cas:authenticationSuccess>
</cas:serviceResponse>
`,
});
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: `
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>test-user</cas:user>
<cas:proxyGrantingTicket>PGTIOU-84678-8a9d...
</cas:proxyGrantingTicket>
</cas:authenticationSuccess>
</cas:serviceResponse>
`,
});
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: `
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationFailure code="INVALID_TICKET">
Ticket ST-1856339-aA5Yuvrxzpv8Tau1cYQ7 not recognized
</cas:authenticationFailure>
</cas:serviceResponse>
`,
});
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`);
});
});

View File

@ -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();
};

View File

@ -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');
});
});

View File

@ -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';

View File

@ -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':

View File

@ -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' });

View File

@ -151,7 +151,7 @@ const Sync = () => {
setLoading(true);
await api.resource('localization').sync({
values: {
type: checkedList,
types: checkedList,
},
});
setLoading(false);

View File

@ -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');
});
});

View File

@ -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() },
);
});
});

View File

@ -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,
});

View File

@ -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,

View File

@ -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);

View File

@ -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',
});
});

View File

@ -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}`);

View File

@ -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`);
});
});

View File

@ -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();
};

View File

@ -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();
};

View File

@ -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,
},
});