226 lines
6.3 KiB
TypeScript
226 lines
6.3 KiB
TypeScript
|
|
import { Controller, Get, Req, UseGuards, Module } from '@nestjs/common';
|
|||
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|||
|
|
import request from 'supertest';
|
|||
|
|
|
|||
|
|
import { SecurityModule } from '../../src/core/security/securityModule';
|
|||
|
|
import { AdminCheckTokenGuard } from '../../src/core/security/adminCheckToken.guard';
|
|||
|
|
import { ApiCheckTokenGuard } from '../../src/core/security/apiCheckToken.guard';
|
|||
|
|
import { ApiOptionalAuthGuard } from '../../src/core/security/apiOptionalAuth.guard';
|
|||
|
|
import { SiteScopeGuard } from '../../src/core/security/siteScopeGuard';
|
|||
|
|
import { TokenAuthService } from '../../src/core/security/tokenAuth.service';
|
|||
|
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|||
|
|
import { CacheModule as NestCacheModule } from '@nestjs/cache-manager';
|
|||
|
|
|
|||
|
|
@Controller()
|
|||
|
|
class SecurityTestController {
|
|||
|
|
// 管理端受保护路由(仅校验 token)
|
|||
|
|
@Get('adminapi/secure')
|
|||
|
|
@UseGuards(AdminCheckTokenGuard)
|
|||
|
|
adminSecure(@Req() req: any) {
|
|||
|
|
return {
|
|||
|
|
appType: req.auth?.('app_type') || req.appType || '',
|
|||
|
|
uid: req.auth?.('uid') ?? req.uid ?? null,
|
|||
|
|
username: req.auth?.('username') || req.username || '',
|
|||
|
|
siteId: req.auth?.('site_id') ?? req.siteId ?? null,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 管理端受保护路由(校验 token + 站点隔离)
|
|||
|
|
@Get('adminapi/secure-site')
|
|||
|
|
@UseGuards(AdminCheckTokenGuard, SiteScopeGuard)
|
|||
|
|
adminSecureSite(@Req() req: any) {
|
|||
|
|
return {
|
|||
|
|
appType: req.auth?.('app_type') || req.appType || '',
|
|||
|
|
uid: req.auth?.('uid') ?? req.uid ?? null,
|
|||
|
|
siteId: req.auth?.('site_id') ?? req.siteId ?? null,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 前台受保护路由(必须登录)
|
|||
|
|
@Get('api/secure')
|
|||
|
|
@UseGuards(ApiCheckTokenGuard)
|
|||
|
|
apiSecure(@Req() req: any) {
|
|||
|
|
return {
|
|||
|
|
appType: req.auth?.('app_type') || req.appType || '',
|
|||
|
|
memberId: req.auth?.('member_id') ?? req.memberId ?? null,
|
|||
|
|
username: req.auth?.('username') || req.username || '',
|
|||
|
|
siteId: req.auth?.('site_id') ?? req.siteId ?? null,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 前台可选登录路由(未登录也可访问)
|
|||
|
|
@Get('api/optional')
|
|||
|
|
@UseGuards(ApiOptionalAuthGuard)
|
|||
|
|
apiOptional(@Req() req: any) {
|
|||
|
|
const hasUser = !!(req.auth?.('member_id') || req.memberId);
|
|||
|
|
return {
|
|||
|
|
appType: req.auth?.('app_type') || req.appType || '',
|
|||
|
|
hasUser,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Module({
|
|||
|
|
imports: [SecurityModule, NestCacheModule.register()],
|
|||
|
|
controllers: [SecurityTestController],
|
|||
|
|
})
|
|||
|
|
class SecurityTestModule {}
|
|||
|
|
|
|||
|
|
describe('Security Guards (e2e)', () => {
|
|||
|
|
let app: any;
|
|||
|
|
let tokenService: TokenAuthService;
|
|||
|
|
|
|||
|
|
// 内存版 Redis 替身(仅实现 get/set/del)
|
|||
|
|
const memory = new Map<string, string>();
|
|||
|
|
const mockRedis = {
|
|||
|
|
async get(key: string) {
|
|||
|
|
return memory.has(key) ? memory.get(key)! : null;
|
|||
|
|
},
|
|||
|
|
async set(key: string, value: string) {
|
|||
|
|
memory.set(key, value);
|
|||
|
|
return 'OK';
|
|||
|
|
},
|
|||
|
|
async del(key: string) {
|
|||
|
|
const existed = memory.delete(key);
|
|||
|
|
return existed ? 1 : 0;
|
|||
|
|
},
|
|||
|
|
} as any;
|
|||
|
|
|
|||
|
|
// 轻量 cache-manager 替身,满足 CACHE_MANAGER 依赖
|
|||
|
|
const mockCache = {
|
|||
|
|
async get<T>(_key: string): Promise<T | null> {
|
|||
|
|
return null;
|
|||
|
|
},
|
|||
|
|
async set<T>(_key: string, _value: T, _ttl?: number): Promise<void> {
|
|||
|
|
return;
|
|||
|
|
},
|
|||
|
|
async del(_key: string): Promise<void> {
|
|||
|
|
return;
|
|||
|
|
},
|
|||
|
|
} as any;
|
|||
|
|
|
|||
|
|
beforeAll(async () => {
|
|||
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|||
|
|
imports: [SecurityTestModule],
|
|||
|
|
})
|
|||
|
|
.overrideProvider('REDIS_CLIENT')
|
|||
|
|
.useValue(mockRedis)
|
|||
|
|
.overrideProvider(CACHE_MANAGER)
|
|||
|
|
.useValue(mockCache)
|
|||
|
|
.compile();
|
|||
|
|
|
|||
|
|
app = (await moduleFixture.createNestApplication()).getHttpServer
|
|||
|
|
? await moduleFixture.createNestApplication()
|
|||
|
|
: await moduleFixture.createNestApplication();
|
|||
|
|
|
|||
|
|
await app.init();
|
|||
|
|
tokenService = moduleFixture.get(TokenAuthService);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterAll(async () => {
|
|||
|
|
if (app) {
|
|||
|
|
await app.close();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('Admin: 401 when token missing', async () => {
|
|||
|
|
await request(app.getHttpServer()).get('/adminapi/secure').expect(401);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('Admin: 200 with valid token (no site header)', async () => {
|
|||
|
|
const { token } = await tokenService.createToken(
|
|||
|
|
1001,
|
|||
|
|
'admin',
|
|||
|
|
{
|
|||
|
|
uid: 1001,
|
|||
|
|
username: 'root',
|
|||
|
|
},
|
|||
|
|
600,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const res = await request(app.getHttpServer())
|
|||
|
|
.get('/adminapi/secure')
|
|||
|
|
.set('token', token)
|
|||
|
|
.expect(200);
|
|||
|
|
|
|||
|
|
expect(res.body.data?.uid ?? res.body.uid).toBe(1001);
|
|||
|
|
const appType = res.body.data?.appType ?? res.body.appType;
|
|||
|
|
expect(appType).toBe('admin');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('Admin: 403 on site mismatch with SiteScopeGuard', async () => {
|
|||
|
|
const { token, params } = await tokenService.createToken(
|
|||
|
|
2002,
|
|||
|
|
'admin',
|
|||
|
|
{
|
|||
|
|
uid: 2002,
|
|||
|
|
username: 'admin2',
|
|||
|
|
site_id: 2,
|
|||
|
|
},
|
|||
|
|
600,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// header 指定不同 site-id 触发越权
|
|||
|
|
await request(app.getHttpServer())
|
|||
|
|
.get('/adminapi/secure-site')
|
|||
|
|
.set('token', token)
|
|||
|
|
.set('site-id', '3')
|
|||
|
|
.expect(403);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('Admin: 200 when site matches with SiteScopeGuard', async () => {
|
|||
|
|
const { token } = await tokenService.createToken(
|
|||
|
|
2003,
|
|||
|
|
'admin',
|
|||
|
|
{
|
|||
|
|
uid: 2003,
|
|||
|
|
username: 'admin3',
|
|||
|
|
site_id: 5,
|
|||
|
|
},
|
|||
|
|
600,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const res = await request(app.getHttpServer())
|
|||
|
|
.get('/adminapi/secure-site')
|
|||
|
|
.set('token', token)
|
|||
|
|
.set('site-id', '5')
|
|||
|
|
.expect(200);
|
|||
|
|
|
|||
|
|
expect(res.body.data?.siteId ?? res.body.siteId).toBe(5);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('API: 401 when token missing', async () => {
|
|||
|
|
await request(app.getHttpServer()).get('/api/secure').expect(401);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('API: 200 with valid token', async () => {
|
|||
|
|
const { token } = await tokenService.createToken(
|
|||
|
|
3001,
|
|||
|
|
'api',
|
|||
|
|
{
|
|||
|
|
member_id: 3001,
|
|||
|
|
username: 'member1',
|
|||
|
|
site_id: 8,
|
|||
|
|
},
|
|||
|
|
600,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const res = await request(app.getHttpServer())
|
|||
|
|
.get('/api/secure')
|
|||
|
|
.set('token', token)
|
|||
|
|
.expect(200);
|
|||
|
|
|
|||
|
|
const memberId = res.body.data?.memberId ?? res.body.memberId;
|
|||
|
|
expect(memberId).toBe(3001);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('API Optional: 200 without token and no user', async () => {
|
|||
|
|
const res = await request(app.getHttpServer())
|
|||
|
|
.get('/api/optional')
|
|||
|
|
.expect(200);
|
|||
|
|
|
|||
|
|
const hasUser = res.body.data?.hasUser ?? res.body.hasUser;
|
|||
|
|
expect(hasUser).toBe(false);
|
|||
|
|
});
|
|||
|
|
});
|