启动前后台后进行单元测试以验证拦截器是否生效,并修正在集成测试中发现的一些问题。 ## 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) | - |