启动前后台后进行单元测试以验证拦截器是否生效,并修正在集成测试中发现的一些问题。
## CORS错误
点击登录按钮后发现如下错误:
```
Access to XMLHttpRequest at 'http://localhost:8080/Teacher/login' from origin 'http://localhost:4200' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
```
该错误是个老生常谈的问题,教程伊始便与该错误打过交道。它的原因是后台没有返回对应的`Access-Control-Allow-Origin`,解决的方法是对应添加跨域设置。而我们在上一个小节的拦截器环节中确认并没有动跨域的任何设置,所以错误的方向还是应该由拦截器入手。
找到后台的日志简单浏览一下看是否能够得到一些有帮助的信息,日志中有以下两条:
```
请求的地址为/Teacher/login请求的方法为:OPTIONS➊
当前token未绑定登录用户,返回401
```
➊ 在前台的代码明明使用的是`this.httpClient.post`方法,为何在用户登录时后台会接收到options请求呢?。这是由于浏览器在进行跨域访问时,如果发现请求的方法不是`get`,那么在请求以前则会向该请求地址(此时为/Teacher/login)发送`options`方法来确认后台允许前台发起的请求方法。仍然以登录为例:当后台返回的允许请求方法中包括了 `POST`方法时,浏览器才会向`/Teacher/login`进行`post`请求,否则将放弃请求。
所以才有了在教程开始时的这段配置代码:
config/WebConfig.java
```java
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:4200")
.allowedMethods("PUT", "DELETE", "POST", "GET", "PATCH")
.exposedHeaders("auth-token");
}
```
上述的代码的是在说:当由`http://localhost:4200`发起对本系统任意地址(`/**`)的访问请求,允许发起"PUT", "DELETE", "POST", "GET", "PATCH"5种请求方法,同时允许前台获取`header`中的"auth-token"。
但由于在整体访问流程中,拦截器早于此处代码段执行,所以还未执行到此代码段的生效位置,就被拦截器截胡了。解决的方法是:在拦截中获取请求方法为`options`中直接放行:
interceptor/AuthInterceptor.java
```java
System.out.println("请求的地址为" + url + "请求的方法为:" + method);
if( "OPTIONS".equals(method)) {
// 请求方法为OPTIONS,不拦截
return true;
}
// 判断请求地址、方法是否与用户登录相同
```
重新启动后台,继续测试。
## 刷新错误
使用用户名密码登录系统后,在任意界面进行刷新都将在控制台发生如下网络错误:
![](https://img.kancloud.cn/b2/92/b2926ca56f5354486545e2bdaa8fd1a8_1178x275.png)
点击任意错误请求后,点击响应header上的 view source
![](https://img.kancloud.cn/ee/f8/eef8f445e6df37c389ed220f0c92f6a8_951x225.png)
发现错误的类型均为401
![](https://img.kancloud.cn/95/24/9524d50c49b63686beb8e9d34a0c5ac2_587x187.png)
这是由于在进行页面刷新时前台用于存储auth-token的CacheService重新进行了初始化。而在初始化的过程中,将auth-token重置为undefined的原因:
service/cache.service.ts
```typescript
export class CacheService {
/** 认证令牌 */
private static authToken: string = undefined; ➊
```
* ➊ 刷新前台时authToken被重置为undefined
这个问题与前面碰到的由于未对登录状态进行缓存,从而导致每次刷新浏览器都要重新登录一次的原因是一样的。解决的方法也一样:使用浏览器提供的缓存来存储auth-token,以保证用户在进行浏览器刷新时能够保持auth-token不变:
service/cache.service.ts
```typescript
private static authToken: string = undefined; ✘
private static authToken: string =
sessionStorage.getItem('authToken') === null ? undefined : sessionStorage.getItem('authToken'); ➊
constructor() {
}
static setAuthToken(token: string) {
CacheService.authToken = token;
sessionStorage.setItem('authToken', token); ➋
}
```
* ➊ 使用sessionStorage存储的值设置authToken
* ➋ 更新sessionStorage存储
sessionStorage获取某个不存在项时返回了null,这与CacheService的authToken的默认值为undefined不同。所以在初始化时,需要使用比目运算符进行转换。如果将CacheService的authToken的默认值同样设置为null。代码还会精简一些:
service/cache.service.ts
```typescript
private static authToken: string = ✘
sessionStorage.getItem('authToken') === null ? undefined : sessionStorage.getItem('authToken'); ✘
private static authToken: string = sessionStorage.getItem('authToken');
...
static getAuthToken() {
if (CacheService.authToken === undefined) { ➊ ✘
if (CacheService.authToken === null) {
return ''; ➋
}
return CacheService.authToken;
}
```
* ➊ 由于angular在在处理header的过程中遇到值为undefined时会报异常,所以当authToken的值为undefined时对应返回`''`➋以规避以异常。
**注意:** 你此时需要参考下图清下缓存
![](https://img.kancloud.cn/50/9d/509db51e1ed2fea9531cab814e49737f_1194x406.png)
## logout
最后再修正下这个看不到的注销。当前的注销功能并未调用后台对应的logout接口。这将导致用户注销后实质上为后台为当前窗口分配的auth-token仍然是生效的。这增加了系统数据被渗透的风险。用户点击注销时只有真正的触发后台的注销接口,才会起到auth-token与用户的解绑作用。
为此,为service/teacher.service.ts新增logout方法如下:
service/teacher.service.ts
```typescript
/**
* 注销
*/
logout(): Observable<void> {
const url = 'http://localhost:8080/Teacher/logout';
return this.httpClient.get<void>(url);
}
```
测试过程略。
<hr>
在C层的注销方法中调用logout方法:
nav/nav.component.ts
```typescript
onLogout() {
this.teacherService.logout()
.subscribe(() => {
this.teacherService.setIsLogin(false);
});
}
```
修正单元测试如下:
nav/nav.component.spec.ts
```typescript
fit('onLogout', () => {
const service = TestBed.get(TeacherService) as TeacherService;
spyOn(service, 'setIsLogin');
spyOn(service, 'logout').and.returnValue(of(null)); ➊
component.onLogout();
expect(service.logout).toHaveBeenCalled(); ➋
expect(service.setIsLogin).toHaveBeenCalledWith(false);
});
```
* ➊ 设置logout方法的替身,并指定替身的返回值
* ➋ 断言方法被调用
测试结果:
![](https://img.kancloud.cn/9f/55/9f550f7fa30ee0195d9ffd9a66094391_428x119.png)
这是由于没有为TeacherService的测试替身TeacherStubService同步添加logout的原因所致
test/service/teacher-stub.service.ts
```typescript
logout(): Observable<void> {
return of(null);
}
```
再次运行单元测试通过。
# 测试结果
![](https://img.kancloud.cn/b6/5f/b65f4927c97f95a83b637ff1e16baee8_1418x395.gif)
# 参考文档
| 名称 | 链接 | 预计学习时长(分) |
| --- | --- | --- |
| 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step5.2.9](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step5.2.9) | - |
| spring-mvc-handlerinterceptor | [https://www.baeldung.com/spring-mvc-handlerinterceptor](https://www.baeldung.com/spring-mvc-handlerinterceptor) | - |
| HttpServletRequest | [https://docs.oracle.com/javaee/6/api/javax/servlet/http/HttpServletRequest.html](https://docs.oracle.com/javaee/6/api/javax/servlet/http/HttpServletRequest.html) | - |
- 序言
- 第一章:Hello World
- 第一节:Angular准备工作
- 1 Node.js
- 2 npm
- 3 WebStorm
- 第二节:Hello Angular
- 第三节:Spring Boot准备工作
- 1 JDK
- 2 MAVEN
- 3 IDEA
- 第四节:Hello Spring Boot
- 1 Spring Initializr
- 2 Hello Spring Boot!
- 3 maven国内源配置
- 4 package与import
- 第五节:Hello Spring Boot + Angular
- 1 依赖注入【前】
- 2 HttpClient获取数据【前】
- 3 数据绑定【前】
- 4 回调函数【选学】
- 第二章 教师管理
- 第一节 数据库初始化
- 第二节 CRUD之R查数据
- 1 原型初始化【前】
- 2 连接数据库【后】
- 3 使用JDBC读取数据【后】
- 4 前后台对接
- 5 ng-if【前】
- 6 日期管道【前】
- 第三节 CRUD之C增数据
- 1 新建组件并映射路由【前】
- 2 模板驱动表单【前】
- 3 httpClient post请求【前】
- 4 保存数据【后】
- 5 组件间调用【前】
- 第四节 CRUD之U改数据
- 1 路由参数【前】
- 2 请求映射【后】
- 3 前后台对接【前】
- 4 更新数据【前】
- 5 更新某个教师【后】
- 6 路由器链接【前】
- 7 观察者模式【前】
- 第五节 CRUD之D删数据
- 1 绑定到用户输入事件【前】
- 2 删除某个教师【后】
- 第六节 代码重构
- 1 文件夹化【前】
- 2 优化交互体验【前】
- 3 相对与绝对地址【前】
- 第三章 班级管理
- 第一节 JPA初始化数据表
- 第二节 班级列表
- 1 新建模块【前】
- 2 初识单元测试【前】
- 3 初始化原型【前】
- 4 面向对象【前】
- 5 测试HTTP请求【前】
- 6 测试INPUT【前】
- 7 测试BUTTON【前】
- 8 @RequestParam【后】
- 9 Repository【后】
- 10 前后台对接【前】
- 第三节 新增班级
- 1 初始化【前】
- 2 响应式表单【前】
- 3 测试POST请求【前】
- 4 JPA插入数据【后】
- 5 单元测试【后】
- 6 惰性加载【前】
- 7 对接【前】
- 第四节 编辑班级
- 1 FormGroup【前】
- 2 x、[x]、{{x}}与(x)【前】
- 3 模拟路由服务【前】
- 4 测试间谍spy【前】
- 5 使用JPA更新数据【后】
- 6 分层开发【后】
- 7 前后台对接
- 8 深入imports【前】
- 9 深入exports【前】
- 第五节 选择教师组件
- 1 初始化【前】
- 2 动态数据绑定【前】
- 3 初识泛型
- 4 @Output()【前】
- 5 @Input()【前】
- 6 再识单元测试【前】
- 7 其它问题
- 第六节 删除班级
- 1 TDD【前】
- 2 TDD【后】
- 3 前后台对接
- 第四章 学生管理
- 第一节 引入Bootstrap【前】
- 第二节 NAV导航组件【前】
- 1 初始化
- 2 Bootstrap格式化
- 3 RouterLinkActive
- 第三节 footer组件【前】
- 第四节 欢迎界面【前】
- 第五节 新增学生
- 1 初始化【前】
- 2 选择班级组件【前】
- 3 复用选择组件【前】
- 4 完善功能【前】
- 5 MVC【前】
- 6 非NULL校验【后】
- 7 唯一性校验【后】
- 8 @PrePersist【后】
- 9 CM层开发【后】
- 10 集成测试
- 第六节 学生列表
- 1 分页【后】
- 2 HashMap与LinkedHashMap
- 3 初识综合查询【后】
- 4 综合查询进阶【后】
- 5 小试综合查询【后】
- 6 初始化【前】
- 7 M层【前】
- 8 单元测试与分页【前】
- 9 单选与多选【前】
- 10 集成测试
- 第七节 编辑学生
- 1 初始化【前】
- 2 嵌套组件测试【前】
- 3 功能开发【前】
- 4 JsonPath【后】
- 5 spyOn【后】
- 6 集成测试
- 7 @Input 异步传值【前】
- 8 值传递与引入传递
- 9 @PreUpdate【后】
- 10 表单验证【前】
- 第八节 删除学生
- 1 CSS选择器【前】
- 2 confirm【前】
- 3 功能开发与测试【后】
- 4 集成测试
- 5 定制提示框【前】
- 6 引入图标库【前】
- 第九节 集成测试
- 第五章 登录与注销
- 第一节:普通登录
- 1 原型【前】
- 2 功能设计【前】
- 3 功能设计【后】
- 4 应用登录组件【前】
- 5 注销【前】
- 6 保留登录状态【前】
- 第二节:你是谁
- 1 过滤器【后】
- 2 令牌机制【后】
- 3 装饰器模式【后】
- 4 拦截器【前】
- 5 RxJS操作符【前】
- 6 用户登录与注销【后】
- 7 个人中心【前】
- 8 拦截器【后】
- 9 集成测试
- 10 单例模式
- 第六章 课程管理
- 第一节 新增课程
- 1 初始化【前】
- 2 嵌套组件测试【前】
- 3 async管道【前】
- 4 优雅的测试【前】
- 5 功能开发【前】
- 6 实体监听器【后】
- 7 @ManyToMany【后】
- 8 集成测试【前】
- 9 异步验证器【前】
- 10 详解CORS【前】
- 第二节 课程列表
- 第三节 果断
- 1 初始化【前】
- 2 分页组件【前】
- 2 分页组件【前】
- 3 综合查询【前】
- 4 综合查询【后】
- 4 综合查询【后】
- 第节 班级列表
- 第节 教师列表
- 第节 编辑课程
- TODO返回机制【前】
- 4 弹出框组件【前】
- 5 多路由出口【前】
- 第节 删除课程
- 第七章 权限管理
- 第一节 AOP
- 总结
- 开发规范
- 备用