admin-session.service.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import { provideHttpClient } from '@angular/common/http';
  2. import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
  3. import { TestBed } from '@angular/core/testing';
  4. import { Router } from '@angular/router';
  5. import { ADMIN_PARSE_URL, ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL } from './admin.constants';
  6. import { AdminSessionService } from './admin-session.service';
  7. describe('AdminSessionService', () => {
  8. let http: HttpTestingController;
  9. let router: jasmine.SpyObj<Router>;
  10. beforeEach(() => {
  11. sessionStorage.clear();
  12. localStorage.removeItem(ADMIN_SESSION_KEY);
  13. router = jasmine.createSpyObj<Router>('Router', ['navigate']);
  14. TestBed.configureTestingModule({
  15. providers: [provideHttpClient(), provideHttpClientTesting(), AdminSessionService, { provide: Router, useValue: router }],
  16. });
  17. http = TestBed.inject(HttpTestingController);
  18. });
  19. afterEach(() => {
  20. http.verify();
  21. sessionStorage.clear();
  22. localStorage.removeItem(ADMIN_SESSION_KEY);
  23. });
  24. it('logs administrators in through the app cloud gateway', async () => {
  25. const service = TestBed.inject(AdminSessionService);
  26. const result = service.login(' admin ', 'secret-pass');
  27. const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`);
  28. expect(request.request.body).toEqual({ params: { action: 'user_login_passwd', name: 'admin', passwd: 'secret-pass' } });
  29. request.flush({ retcode: 0, result: { objectId: 'admin-1', userName: 'admin', honeyName: '管理员', sessionToken: 'r:admin-token' } });
  30. await expectAsync(result).toBeResolvedTo(jasmine.objectContaining({ objectId: 'admin-1', username: 'admin', displayName: '管理员', sessionToken: 'r:admin-token' }));
  31. expect(JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY) || '{}').sessionToken).toBe('r:admin-token');
  32. expect(JSON.parse(localStorage.getItem(ADMIN_SESSION_KEY) || '{}').sessionToken).toBe('r:admin-token');
  33. });
  34. it('revokes the Parse session through the admin cloud function on logout', () => {
  35. sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', username: 'root', sessionToken: 'r:admin-token' }));
  36. const service = TestBed.inject(AdminSessionService);
  37. service.logout(false);
  38. const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`);
  39. expect(request.request.body).toEqual({ token: 'r:admin-token', params: { operation: 'logout', sessionToken: 'r:admin-token' } });
  40. expect(service.session()).toBeNull();
  41. expect(sessionStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();
  42. request.flush({ success: true, data: { revoked: true } });
  43. });
  44. it('restores a login after the previous tab was closed and mirrors it for route caches', () => {
  45. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'saved'}));
  46. const service=TestBed.inject(AdminSessionService);
  47. expect(service.session()?.sessionToken).toBe('saved');
  48. expect(JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('saved');
  49. });
  50. it('migrates an existing tab login without requiring another password', () => {
  51. sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'legacy'}));
  52. expect(TestBed.inject(AdminSessionService).session()?.sessionToken).toBe('legacy');
  53. expect(JSON.parse(localStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('legacy');
  54. });
  55. it('ignores failure from an older session and never revokes the new one', () => {
  56. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'new'}));
  57. const service=TestBed.inject(AdminSessionService);
  58. service.invalidateSession('old');
  59. expect(service.session()?.sessionToken).toBe('new');
  60. expect(router.navigate).not.toHaveBeenCalled();
  61. http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`);
  62. });
  63. it('clears a confirmed expired session once, without sending a logout request', () => {
  64. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'expired'}));
  65. const service=TestBed.inject(AdminSessionService);
  66. service.invalidateSession('expired');service.invalidateSession('expired');
  67. expect(service.session()).toBeNull();expect(localStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();
  68. expect(sessionStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();expect(router.navigate).toHaveBeenCalledTimes(1);
  69. http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`);
  70. });
  71. it('shares logout across tabs without another server logout call', () => {
  72. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'shared'}));
  73. const service=TestBed.inject(AdminSessionService);
  74. localStorage.removeItem(ADMIN_SESSION_KEY);
  75. window.dispatchEvent(new StorageEvent('storage',{key:ADMIN_SESSION_KEY,newValue:null,storageArea:localStorage}));
  76. expect(service.session()).toBeNull();expect(sessionStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();
  77. expect(router.navigate).toHaveBeenCalled();
  78. });
  79. it('deduplicates gateway-failure checks and retains a session that Parse confirms is valid', async () => {
  80. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'valid'}));
  81. const service=TestBed.inject(AdminSessionService);
  82. const a=service.verifySessionAfterFailure('valid'),b=service.verifySessionAfterFailure('valid');
  83. expect(a).toBe(b);
  84. const req=http.expectOne(`${ADMIN_PARSE_URL}/users/me`);
  85. expect(req.request.headers.get('X-Parse-Session-Token')).toBe('valid');req.flush({objectId:'admin-1'});
  86. await a;expect(service.isAuthenticated()).toBeTrue();expect(router.navigate).not.toHaveBeenCalled();
  87. });
  88. it('requires re-login after Parse confirms an invalid token', async () => {
  89. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'expired'}));
  90. const service=TestBed.inject(AdminSessionService),pending=service.verifySessionAfterFailure('expired');
  91. http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({code:209,error:'invalid session token'},{status:400,statusText:'Bad Request'});
  92. await pending;expect(service.isAuthenticated()).toBeFalse();expect(router.navigate).toHaveBeenCalled();
  93. });
  94. it('keeps the login when session verification has a network failure', async () => {
  95. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'valid'}));
  96. const service=TestBed.inject(AdminSessionService),pending=service.verifySessionAfterFailure('valid');
  97. http.expectOne(`${ADMIN_PARSE_URL}/users/me`).error(new ProgressEvent('network'));
  98. await pending;expect(service.isAuthenticated()).toBeTrue();
  99. });
  100. it('does not apply stale identity metadata to a different account',()=>{
  101. localStorage.setItem(ADMIN_SESSION_KEY,JSON.stringify({objectId:'new-admin',sessionToken:'new'}));
  102. const service=TestBed.inject(AdminSessionService);
  103. service.applyIdentity({objectId:'old-admin',username:'old',displayName:'old',roles:['super-admin'],isSuperAdmin:true,operationsRole:'ops-manager'});
  104. expect(service.session()?.objectId).toBe('new-admin');expect(service.session()?.isSuperAdmin).toBeUndefined();
  105. });
  106. it('keeps the login when the verification endpoint returns an unclassified 401', async () => {
  107. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'valid' }));
  108. const service = TestBed.inject(AdminSessionService);
  109. const pending = service.verifySessionAfterFailure('valid');
  110. http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
  111. await pending;
  112. expect(service.isAuthenticated()).toBeTrue();
  113. expect(localStorage.getItem(ADMIN_SESSION_KEY)).not.toBeNull();
  114. expect(router.navigate).not.toHaveBeenCalled();
  115. });
  116. it('keeps the login when verification returns an unexpected success body', async () => {
  117. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'valid' }));
  118. const service = TestBed.inject(AdminSessionService);
  119. const pending = service.verifySessionAfterFailure('valid');
  120. http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ message: 'upstream unavailable' });
  121. await pending;
  122. expect(service.isAuthenticated()).toBeTrue();
  123. expect(router.navigate).not.toHaveBeenCalled();
  124. });
  125. it('does not clear a newer shared login before its storage event arrives', async () => {
  126. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'old' }));
  127. const service = TestBed.inject(AdminSessionService);
  128. const pending = service.verifySessionAfterFailure('old');
  129. // Another tab logs in while this request is in flight, before storage events are delivered.
  130. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'new' }));
  131. http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ code: 209, error: 'invalid session token' }, { status: 400, statusText: 'Bad Request' });
  132. await pending;
  133. expect(service.session()?.sessionToken).toBe('new');
  134. expect(JSON.parse(localStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('new');
  135. expect(JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('new');
  136. expect(router.navigate).not.toHaveBeenCalled();
  137. });
  138. it('reads the latest shared login instead of replaying a queued logout event', () => {
  139. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'old' }));
  140. const service = TestBed.inject(AdminSessionService);
  141. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'new' }));
  142. window.dispatchEvent(new StorageEvent('storage', { key: ADMIN_SESSION_KEY, newValue: null, storageArea: localStorage }));
  143. expect(service.session()?.sessionToken).toBe('new');
  144. expect(router.navigate).not.toHaveBeenCalled();
  145. });
  146. it('does not overwrite another tabs login with a late identity response', () => {
  147. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'old' }));
  148. const service = TestBed.inject(AdminSessionService);
  149. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-2', sessionToken: 'new' }));
  150. service.applyIdentity({ objectId: 'admin-1', username: 'old', displayName: 'old', roles: ['super-admin'], isSuperAdmin: true, operationsRole: 'ops-manager' });
  151. expect(service.session()?.objectId).toBe('admin-2');
  152. expect(JSON.parse(localStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('new');
  153. });
  154. it('still rejects a verified account mismatch', async () => {
  155. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'invalid' }));
  156. const service = TestBed.inject(AdminSessionService);
  157. const pending = service.verifySessionAfterFailure('invalid');
  158. http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ objectId: 'different-admin' });
  159. await pending;
  160. expect(service.isAuthenticated()).toBeFalse();
  161. expect(router.navigate).toHaveBeenCalled();
  162. });
  163. it('preserves a tab login when persistent writes are unavailable', async () => {
  164. const originalSetItem = Storage.prototype.setItem;
  165. spyOn(Storage.prototype, 'setItem').and.callFake(function(this: Storage, key: string, value: string) {
  166. if (this === localStorage) throw new DOMException('Storage full', 'QuotaExceededError');
  167. originalSetItem.call(this, key, value);
  168. });
  169. sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'valid' }));
  170. const service = TestBed.inject(AdminSessionService);
  171. const pending = service.verifySessionAfterFailure('valid');
  172. http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ objectId: 'admin-1' });
  173. await pending;
  174. expect(service.session()?.sessionToken).toBe('valid');
  175. expect(router.navigate).not.toHaveBeenCalled();
  176. });
  177. it('still rejects a verified disabled account', async () => {
  178. localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'disabled' }));
  179. const service = TestBed.inject(AdminSessionService);
  180. const pending = service.verifySessionAfterFailure('disabled');
  181. http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ objectId: 'admin-1', isDisabled: true });
  182. await pending;
  183. expect(service.isAuthenticated()).toBeFalse();
  184. expect(router.navigate).toHaveBeenCalledTimes(1);
  185. });
  186. });