mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
WIP
This commit is contained in:
@@ -19,8 +19,15 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TimeSyncService } from './time-sync.service';
|
||||
import { LogService } from '../../common/services/log.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
// A fixed reference instant (Wed, 15 Jan 2025 12:00:00 GMT), aligned to a whole second.
|
||||
const BASE = Date.UTC(2025, 0, 15, 12, 0, 0);
|
||||
|
||||
// Formats an epoch (ms) as an RFC 7231 GMT date string, exactly as an HTTP `Date` header.
|
||||
const toHttpDate = (epochMs: number): string => new Date(epochMs).toUTCString();
|
||||
|
||||
describe('TimeSyncService', () => {
|
||||
let service: TimeSyncService;
|
||||
let httpMock: HttpTestingController;
|
||||
@@ -39,6 +46,61 @@ describe('TimeSyncService', () => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
// Simulates a single clock sync with zero network round-trip time so that the resulting
|
||||
// offset is simply `serverEpochMs - localNowMs`. Both timestamps are absolute UTC epochs,
|
||||
// mirroring how `Date.now()` and a parsed `Date` header behave in production.
|
||||
const syncWithDrift = async (localNowMs: number, serverEpochMs: number): Promise<void> => {
|
||||
spyOn(Date, 'now').and.returnValues(localNowMs, localNowMs); // startTime, endTime (round-trip 0)
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
|
||||
const req = httpMock.expectOne(() => true);
|
||||
req.flush(null, { headers: { date: toHttpDate(serverEpochMs) } });
|
||||
|
||||
await promise;
|
||||
};
|
||||
|
||||
// Converts a skew magnitude + direction into the server instant relative to a local clock at
|
||||
// BASE. "behind" means the local clock reads earlier than the server (client slow); "ahead"
|
||||
// means it reads later (client fast).
|
||||
const serverInstantFor = (skewSeconds: number, direction: 'behind' | 'ahead'): number =>
|
||||
direction === 'behind' ? BASE + skewSeconds * 1000 : BASE - skewSeconds * 1000;
|
||||
|
||||
// Runs one sync at BASE against the given skew and returns both the stored offset and the
|
||||
// resulting corrected "now". Uses three Date.now() values: startTime, endTime, getCorrectedNow.
|
||||
const syncAndReadCorrectedNow = async (serverEpochMs: number): Promise<{ offset: number; correctedNow: number }> => {
|
||||
spyOn(Date, 'now').and.returnValues(BASE, BASE, BASE); // startTime, endTime, getCorrectedNow
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(serverEpochMs) } });
|
||||
await promise;
|
||||
|
||||
return { offset: service.clockOffsetMs, correctedNow: service.getCorrectedNow() };
|
||||
};
|
||||
|
||||
// Asserts the correction realigns the corrected clock exactly onto the server instant, which is
|
||||
// what keeps login / token refresh / the session working in the new UI for a tolerated skew.
|
||||
const expectCorrectionAlignsToServer = async (skewSeconds: number, direction: 'behind' | 'ahead'): Promise<void> => {
|
||||
const serverEpochMs = serverInstantFor(skewSeconds, direction);
|
||||
const expectedOffset = direction === 'behind' ? skewSeconds * 1000 : -skewSeconds * 1000;
|
||||
|
||||
const { offset, correctedNow } = await syncAndReadCorrectedNow(serverEpochMs);
|
||||
|
||||
expect(offset).toBe(expectedOffset);
|
||||
expect(correctedNow).toBe(serverEpochMs);
|
||||
};
|
||||
|
||||
// Asserts the correction is rejected (implausible skew beyond the clamp): the offset is left at
|
||||
// 0 and the corrected clock falls back to the raw local clock.
|
||||
const expectCorrectionRejected = async (skewSeconds: number, direction: 'behind' | 'ahead'): Promise<void> => {
|
||||
const serverEpochMs = serverInstantFor(skewSeconds, direction);
|
||||
|
||||
const { offset, correctedNow } = await syncAndReadCorrectedNow(serverEpochMs);
|
||||
|
||||
expect(offset).toBe(0);
|
||||
expect(correctedNow).toBe(BASE);
|
||||
};
|
||||
|
||||
describe('syncClockOffset', () => {
|
||||
it('should store a positive offset when the local clock is behind the server', async () => {
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
@@ -180,11 +242,12 @@ describe('TimeSyncService', () => {
|
||||
});
|
||||
|
||||
describe('startPeriodicSync', () => {
|
||||
it('should re-sync on visibility change', async () => {
|
||||
it('should re-sync on visibility change', () => {
|
||||
const debounceCheckTime = 1728911579000;
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
const timeResponseReceived = 1728911580000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived);
|
||||
spyOn(Date, 'now').and.returnValues(debounceCheckTime, timeBeforeRequest, timeResponseReceived);
|
||||
|
||||
service.startPeriodicSync(60000);
|
||||
|
||||
@@ -210,4 +273,394 @@ describe('TimeSyncService', () => {
|
||||
httpMock.expectNone(() => true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clock drift combinations', () => {
|
||||
const driftScenarios: { description: string; driftMs: number; expectedOffsetMs: number }[] = [
|
||||
{ description: 'client and server are perfectly in sync', driftMs: 0, expectedOffsetMs: 0 },
|
||||
{ description: 'client is 1s behind the server (client slightly slow)', driftMs: 1_000, expectedOffsetMs: 1_000 },
|
||||
{ description: 'client is 1s ahead of the server (client slightly fast)', driftMs: -1_000, expectedOffsetMs: -1_000 },
|
||||
{ description: 'client is 45s behind the server (client slow)', driftMs: 45_000, expectedOffsetMs: 45_000 },
|
||||
{ description: 'client is 45s ahead of the server (client fast)', driftMs: -45_000, expectedOffsetMs: -45_000 },
|
||||
{ description: 'client is 5m behind the server', driftMs: 300_000, expectedOffsetMs: 300_000 },
|
||||
{ description: 'client is 5m ahead of the server', driftMs: -300_000, expectedOffsetMs: -300_000 },
|
||||
{ description: 'client is 9m behind the server (near the upper bound)', driftMs: 540_000, expectedOffsetMs: 540_000 },
|
||||
{ description: 'client is 9m ahead of the server (near the upper bound)', driftMs: -540_000, expectedOffsetMs: -540_000 },
|
||||
{ description: 'client is exactly 10m behind the server (at the bound)', driftMs: 600_000, expectedOffsetMs: 600_000 },
|
||||
{ description: 'client is exactly 10m ahead of the server (at the bound)', driftMs: -600_000, expectedOffsetMs: -600_000 }
|
||||
];
|
||||
|
||||
driftScenarios.forEach(({ description, driftMs, expectedOffsetMs }) => {
|
||||
it(`should compute the correct offset when ${description}`, async () => {
|
||||
await syncWithDrift(BASE, BASE + driftMs);
|
||||
|
||||
expect(service.clockOffsetMs).toBe(expectedOffsetMs);
|
||||
});
|
||||
});
|
||||
|
||||
it('should make getCorrectedNow report the server instant after correcting a fast client clock', async () => {
|
||||
const localNow = BASE + 300_000; // client wall clock is 5 minutes ahead of the server
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow); // start, end, getCorrectedNow
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE) } });
|
||||
await promise;
|
||||
|
||||
expect(service.clockOffsetMs).toBe(-300_000);
|
||||
expect(service.getCorrectedNow()).toBe(BASE);
|
||||
});
|
||||
|
||||
it('should make getCorrectedNow report the server instant after correcting a slow client clock', async () => {
|
||||
const localNow = BASE - 300_000; // client wall clock is 5 minutes behind the server
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow); // start, end, getCorrectedNow
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE) } });
|
||||
await promise;
|
||||
|
||||
expect(service.clockOffsetMs).toBe(300_000);
|
||||
expect(service.getCorrectedNow()).toBe(BASE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offset bounds (security guard)', () => {
|
||||
const rejectedScenarios: { description: string; driftMs: number }[] = [
|
||||
{ description: 'client is 11m behind the server', driftMs: 660_000 },
|
||||
{ description: 'client is 11m ahead of the server', driftMs: -660_000 },
|
||||
{ description: 'client is 5h behind the server', driftMs: 5 * 60 * 60 * 1000 },
|
||||
{ description: 'client is 5h ahead of the server', driftMs: -5 * 60 * 60 * 1000 }
|
||||
];
|
||||
|
||||
rejectedScenarios.forEach(({ description, driftMs }) => {
|
||||
it(`should ignore an implausible offset and keep the previous value when ${description}`, async () => {
|
||||
service.clockOffsetMs = 1234; // a previously trusted, plausible offset
|
||||
|
||||
await syncWithDrift(BASE, BASE + driftMs);
|
||||
|
||||
expect(service.clockOffsetMs).toBe(1234);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject an offset just beyond a custom maxAllowedOffsetMs bound', async () => {
|
||||
service.maxAllowedOffsetMs = 1000;
|
||||
service.clockOffsetMs = 0;
|
||||
|
||||
await syncWithDrift(BASE, BASE + 2000);
|
||||
|
||||
expect(service.clockOffsetMs).toBe(0);
|
||||
});
|
||||
|
||||
it('should apply an offset within a custom maxAllowedOffsetMs bound', async () => {
|
||||
service.maxAllowedOffsetMs = 5000;
|
||||
|
||||
await syncWithDrift(BASE, BASE + 4000);
|
||||
|
||||
expect(service.clockOffsetMs).toBe(4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time zone independence', () => {
|
||||
it('should treat an instant expressed in GMT as in sync with an equal UTC epoch', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(BASE, BASE);
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: 'Wed, 15 Jan 2025 12:00:00 GMT' } });
|
||||
await promise;
|
||||
|
||||
expect(service.clockOffsetMs).toBe(0);
|
||||
});
|
||||
|
||||
it('should compare absolute instants regardless of the client machine time zone', async () => {
|
||||
// `Date.now()` and a parsed `Date` header are both absolute UTC epochs, so a client
|
||||
// whose wall clock is expressed in a different time zone still yields the same offset.
|
||||
const localNow = BASE + 120_000; // 2 minutes of genuine drift, whatever the local zone
|
||||
|
||||
await syncWithDrift(localNow, BASE);
|
||||
|
||||
expect(service.clockOffsetMs).toBe(-120_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('observability', () => {
|
||||
let warnSpy: jasmine.Spy;
|
||||
let debugSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
const logService = TestBed.inject(LogService);
|
||||
warnSpy = spyOn(logService, 'warn');
|
||||
debugSpy = spyOn(logService, 'debug');
|
||||
});
|
||||
|
||||
it('should log at debug level when the Date header is missing', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(BASE, BASE);
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).flush(null, { headers: {} });
|
||||
await promise;
|
||||
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log at debug level when the Date header cannot be parsed', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(BASE, BASE);
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: 'not-a-valid-date' } });
|
||||
await promise;
|
||||
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should strip control characters from the Date header before logging it', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(BASE, BASE);
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: 'bogus\r\ninjected-line' } });
|
||||
await promise;
|
||||
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
const loggedMessage = debugSpy.calls.mostRecent().args[0] as string;
|
||||
expect(loggedMessage).not.toContain('\r');
|
||||
expect(loggedMessage).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('should log at warn level when an implausible offset is ignored', async () => {
|
||||
await syncWithDrift(BASE, BASE + 60 * 60 * 1000);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log at debug level when the request fails', async () => {
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).error(new ProgressEvent('error'));
|
||||
await promise;
|
||||
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibility re-sync debounce', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
|
||||
});
|
||||
|
||||
it('should skip a re-sync triggered again within the debounce window', () => {
|
||||
spyOn(Date, 'now').and.returnValues(
|
||||
BASE, // 1st visibility debounce check
|
||||
BASE, // syncClockOffset startTime
|
||||
BASE, // syncClockOffset endTime
|
||||
BASE + 5_000 // 2nd visibility debounce check (still within the 30s window)
|
||||
);
|
||||
|
||||
service.startPeriodicSync(60000);
|
||||
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE) } });
|
||||
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
httpMock.expectNone(() => true);
|
||||
});
|
||||
|
||||
it('should allow a re-sync once the debounce window has elapsed', () => {
|
||||
spyOn(Date, 'now').and.returnValues(
|
||||
BASE, // 1st debounce check
|
||||
BASE, // start
|
||||
BASE, // end
|
||||
BASE + 31_000, // 2nd debounce check (after the 30s window)
|
||||
BASE + 31_000, // start
|
||||
BASE + 31_000 // end
|
||||
);
|
||||
|
||||
service.startPeriodicSync(60000);
|
||||
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE) } });
|
||||
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE + 31_000) } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clock skew scenario matrix (TC)', () => {
|
||||
describe('login-time skew', () => {
|
||||
it('TC-01: baseline login with an accurate clock keeps the corrected clock aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(0, 'behind');
|
||||
});
|
||||
|
||||
it('TC-02: login with a slow clock 119s behind stays aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(119, 'behind');
|
||||
});
|
||||
|
||||
it('TC-03: login with a slow clock 120s behind stays aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(120, 'behind');
|
||||
});
|
||||
|
||||
it('TC-04: login with a slow clock 121s behind stays aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(121, 'behind');
|
||||
});
|
||||
|
||||
it('TC-05: login with a slow clock 3m58s behind stays aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(238, 'behind');
|
||||
});
|
||||
|
||||
it('TC-06: login with a fast clock 119s ahead stays aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(119, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-07: login with a fast clock 120s ahead stays aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(120, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-08: login with a fast clock 121s ahead stays aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(121, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-09: login with a fast clock 3m58s ahead stays aligned to the server', async () => {
|
||||
await expectCorrectionAlignsToServer(238, 'ahead');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime drift after login', () => {
|
||||
it('TC-10: runtime drift 119s behind keeps the corrected clock aligned so refresh succeeds', async () => {
|
||||
await expectCorrectionAlignsToServer(119, 'behind');
|
||||
});
|
||||
|
||||
it('TC-11: runtime drift 120s behind keeps the corrected clock aligned so refresh succeeds', async () => {
|
||||
await expectCorrectionAlignsToServer(120, 'behind');
|
||||
});
|
||||
|
||||
it('TC-12: runtime drift 121s behind keeps the corrected clock aligned so refresh succeeds', async () => {
|
||||
await expectCorrectionAlignsToServer(121, 'behind');
|
||||
});
|
||||
|
||||
it('TC-13: runtime drift 3m58s behind keeps the session aligned (no false logout)', async () => {
|
||||
await expectCorrectionAlignsToServer(238, 'behind');
|
||||
});
|
||||
|
||||
it('TC-14: runtime drift 119s ahead keeps the corrected clock aligned so refresh succeeds', async () => {
|
||||
await expectCorrectionAlignsToServer(119, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-15: runtime drift 120s ahead keeps the corrected clock aligned so refresh succeeds', async () => {
|
||||
await expectCorrectionAlignsToServer(120, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-16: runtime drift 121s ahead keeps the corrected clock aligned so refresh succeeds', async () => {
|
||||
await expectCorrectionAlignsToServer(121, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-17: runtime drift 3m58s ahead keeps the session aligned (no false logout)', async () => {
|
||||
await expectCorrectionAlignsToServer(238, 'ahead');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reload, multi-tab, idle, API failure and relogin', () => {
|
||||
it('TC-18: browser refresh while 3m58s behind realigns the fresh instance to the server', async () => {
|
||||
expect(service.clockOffsetMs).toBe(0); // fresh instance, as after a page reload
|
||||
await expectCorrectionAlignsToServer(238, 'behind');
|
||||
});
|
||||
|
||||
it('TC-19: browser refresh while 3m58s ahead realigns the fresh instance to the server', async () => {
|
||||
expect(service.clockOffsetMs).toBe(0); // fresh instance, as after a page reload
|
||||
await expectCorrectionAlignsToServer(238, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-20: multiple tabs 3m58s behind each read a stable, server-aligned corrected time', async () => {
|
||||
const serverEpochMs = serverInstantFor(238, 'behind');
|
||||
spyOn(Date, 'now').and.returnValues(BASE, BASE, BASE, BASE); // start, end, read #1, read #2
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(serverEpochMs) } });
|
||||
await promise;
|
||||
|
||||
expect(service.clockOffsetMs).toBe(238_000);
|
||||
expect(service.getCorrectedNow()).toBe(serverEpochMs);
|
||||
expect(service.getCorrectedNow()).toBe(serverEpochMs);
|
||||
});
|
||||
|
||||
it('TC-21: idle session 3m58s behind re-syncs and corrects when the tab becomes visible', () => {
|
||||
const serverEpochMs = serverInstantFor(238, 'behind');
|
||||
spyOn(Date, 'now').and.returnValues(BASE, BASE, BASE); // debounce check, start, end
|
||||
|
||||
service.startPeriodicSync(60000);
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(serverEpochMs) } });
|
||||
|
||||
expect(service.clockOffsetMs).toBe(238_000);
|
||||
});
|
||||
|
||||
it('TC-22: idle session 3m58s ahead re-syncs and corrects when the tab becomes visible', () => {
|
||||
const serverEpochMs = serverInstantFor(238, 'ahead');
|
||||
spyOn(Date, 'now').and.returnValues(BASE, BASE, BASE); // debounce check, start, end
|
||||
|
||||
service.startPeriodicSync(60000);
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(serverEpochMs) } });
|
||||
|
||||
expect(service.clockOffsetMs).toBe(-238_000);
|
||||
});
|
||||
|
||||
it('TC-23: time API failure while behind keeps the previous offset (session continues on skew tolerance)', async () => {
|
||||
service.clockOffsetMs = 60_000; // an existing, within-tolerance correction
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).error(new ProgressEvent('error'));
|
||||
await promise;
|
||||
|
||||
expect(service.clockOffsetMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it('TC-24: time API failure while ahead keeps the previous offset (session continues on skew tolerance)', async () => {
|
||||
service.clockOffsetMs = -60_000; // an existing, within-tolerance correction
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
httpMock.expectOne(() => true).error(new ProgressEvent('error'));
|
||||
await promise;
|
||||
|
||||
expect(service.clockOffsetMs).toBe(-60_000);
|
||||
});
|
||||
|
||||
it('TC-25: relogin after logout while 3m58s behind corrects immediately on the next sync', async () => {
|
||||
service.stopPeriodicSync(); // simulate logout tearing down periodic sync
|
||||
await expectCorrectionAlignsToServer(238, 'behind');
|
||||
});
|
||||
|
||||
it('TC-26: relogin after logout while 3m58s ahead corrects immediately on the next sync', async () => {
|
||||
service.stopPeriodicSync(); // simulate logout tearing down periodic sync
|
||||
await expectCorrectionAlignsToServer(238, 'ahead');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clamp boundary (security guard)', () => {
|
||||
it('TC-27: skew 9m59s behind (within the clamp) is corrected', async () => {
|
||||
await expectCorrectionAlignsToServer(599, 'behind');
|
||||
});
|
||||
|
||||
it('TC-28: skew 9m59s ahead (within the clamp) is corrected', async () => {
|
||||
await expectCorrectionAlignsToServer(599, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-29: skew exactly 10m behind (at the clamp) is corrected', async () => {
|
||||
await expectCorrectionAlignsToServer(600, 'behind');
|
||||
});
|
||||
|
||||
it('TC-30: skew exactly 10m ahead (at the clamp) is corrected', async () => {
|
||||
await expectCorrectionAlignsToServer(600, 'ahead');
|
||||
});
|
||||
|
||||
it('TC-31: skew 10m01s behind (beyond the clamp) is rejected and falls back to the local clock', async () => {
|
||||
await expectCorrectionRejected(601, 'behind');
|
||||
});
|
||||
|
||||
it('TC-32: skew 10m01s ahead (beyond the clamp) is rejected and falls back to the local clock', async () => {
|
||||
await expectCorrectionRejected(601, 'ahead');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, Injector, NgZone, inject } from '@angular/core';
|
||||
import { interval, Observable, of, Subscription } from 'rxjs';
|
||||
import { catchError, map, switchMap, timeout } from 'rxjs/operators';
|
||||
import { LogService } from '../../common/services/log.service';
|
||||
|
||||
export interface TimeSync {
|
||||
outOfSync: boolean;
|
||||
@@ -30,12 +31,26 @@ export interface TimeSync {
|
||||
/** Default interval for periodic clock re-sync (5 minutes). */
|
||||
const DEFAULT_PERIODIC_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Default upper bound, in milliseconds, for a clock offset that will be trusted (10 minutes).
|
||||
* Offsets larger than this are treated as implausible and ignored so that a single unsigned
|
||||
* `Date` header cannot arbitrarily extend client-side token validity. See `maxAllowedOffsetMs`.
|
||||
*/
|
||||
const DEFAULT_MAX_ALLOWED_OFFSET_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Minimum delay between visibility-triggered re-syncs (30 seconds) to avoid request storms. */
|
||||
const VISIBILITY_SYNC_DEBOUNCE_MS = 30 * 1000;
|
||||
|
||||
/** Timeout applied to the time-sync HEAD request (5 seconds). */
|
||||
const SYNC_REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TimeSyncService {
|
||||
private readonly _injector = inject(Injector);
|
||||
private readonly _ngZone = inject(NgZone);
|
||||
private readonly _logService = inject(LogService);
|
||||
|
||||
private readonly _http: HttpClient;
|
||||
|
||||
@@ -46,8 +61,18 @@ export class TimeSyncService {
|
||||
*/
|
||||
clockOffsetMs = 0;
|
||||
|
||||
/**
|
||||
* Maximum magnitude, in milliseconds, of a measured clock offset that will be trusted and
|
||||
* applied. Any measured offset whose absolute value exceeds this bound is treated as
|
||||
* implausible (a hostile / misconfigured `Date` header or an unreliable measurement) and is
|
||||
* ignored, so a single response can never arbitrarily extend client-side token validity.
|
||||
* Defaults to 10 minutes.
|
||||
*/
|
||||
maxAllowedOffsetMs = DEFAULT_MAX_ALLOWED_OFFSET_MS;
|
||||
|
||||
private _periodicSyncSubscription: Subscription | null = null;
|
||||
private _visibilityChangeHandler: (() => void) | null = null;
|
||||
private _lastSyncAtMs = 0;
|
||||
|
||||
constructor() {
|
||||
this._http = this._injector.get(HttpClient);
|
||||
@@ -72,6 +97,11 @@ export class TimeSyncService {
|
||||
* The HEAD request is lightweight (no response body) and targets the same origin,
|
||||
* so there are no CORS issues. Nginx always includes a `Date` header in its responses.
|
||||
*
|
||||
* Trust model: the `Date` header is unsigned metadata, so it is treated as a best-effort
|
||||
* hint only. The measured offset is bounded by `maxAllowedOffsetMs` (see the security guard
|
||||
* below) and client-side expiry is only ever a convenience check — the server remains the
|
||||
* sole authority on token validity and enforces `exp` against its own clock on every call.
|
||||
*
|
||||
* @returns Observable that completes after the offset has been stored (or silently on error)
|
||||
*/
|
||||
syncClockOffset(): Observable<void> {
|
||||
@@ -79,18 +109,27 @@ export class TimeSyncService {
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
this._lastSyncAtMs = startTime;
|
||||
return this._http.head(appRootUrl, { observe: 'response', responseType: 'text' }).pipe(
|
||||
timeout(5000),
|
||||
timeout(SYNC_REQUEST_TIMEOUT_MS),
|
||||
map((response) => {
|
||||
const endTime = Date.now();
|
||||
const dateHeader = response.headers.get('date');
|
||||
|
||||
if (!dateHeader) {
|
||||
this._logService.debug('TimeSyncService: response has no Date header; keeping the current clock offset.');
|
||||
return;
|
||||
}
|
||||
|
||||
// The HTTP `Date` header is always expressed in GMT (RFC 7231), and both
|
||||
// `new Date(...).getTime()` and `Date.now()` return absolute epoch
|
||||
// milliseconds in UTC. The offset math below is therefore independent of the
|
||||
// browser's local time zone or daylight-saving settings.
|
||||
const serverTimeInMs = new Date(dateHeader).getTime();
|
||||
if (isNaN(serverTimeInMs)) {
|
||||
this._logService.debug(
|
||||
`TimeSyncService: unable to parse Date header "${this.sanitizeForLog(dateHeader)}"; keeping the current clock offset.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,11 +137,30 @@ export class TimeSyncService {
|
||||
const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2;
|
||||
const newOffset = adjustedServerTimeInMs - endTime;
|
||||
|
||||
// Security guard: never trust an implausibly large correction. The `Date`
|
||||
// header is unsigned, so a hostile / misconfigured proxy could try to push the
|
||||
// corrected clock backwards to keep expired tokens looking valid on the client
|
||||
// (a negative offset extends client-side token lifetime). Bounding the
|
||||
// magnitude caps the worst-case client-side exposure window to
|
||||
// `maxAllowedOffsetMs`; beyond that we ignore the measurement and fall back to
|
||||
// the raw local clock.
|
||||
if (Math.abs(newOffset) > this.maxAllowedOffsetMs) {
|
||||
this._logService.warn(
|
||||
`TimeSyncService: ignoring implausible clock offset of ${Math.round(newOffset)} ms ` +
|
||||
`(exceeds the maximum allowed ${this.maxAllowedOffsetMs} ms). Falling back to the local clock.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clockOffsetMs = newOffset;
|
||||
}),
|
||||
catchError(() => of(void 0))
|
||||
catchError((error) => {
|
||||
this._logService.debug('TimeSyncService: failed to synchronise the clock offset; falling back to the local clock.', error);
|
||||
return of(void 0);
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this._logService.debug('TimeSyncService: unexpected error while synchronising the clock offset; falling back to the local clock.', error);
|
||||
return of(void 0);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +215,11 @@ export class TimeSyncService {
|
||||
|
||||
this._visibilityChangeHandler = () => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||
// Debounce rapid visibility toggles (and multiple resumes across tabs) so we
|
||||
// do not issue a burst of redundant HEAD requests when a session resumes.
|
||||
if (Date.now() - this._lastSyncAtMs < VISIBILITY_SYNC_DEBOUNCE_MS) {
|
||||
return;
|
||||
}
|
||||
this.syncClockOffset().subscribe();
|
||||
}
|
||||
};
|
||||
@@ -186,6 +249,8 @@ export class TimeSyncService {
|
||||
* in the pathname) so that nginx handles the request regardless of app deployment path.
|
||||
*
|
||||
* Example: for `https://host/aae-xxx/ui/workspace-lprbu/`, returns that same URL.
|
||||
*
|
||||
* @returns the application root URL used for time-sync HEAD requests
|
||||
*/
|
||||
private getAppRootUrl(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -193,4 +258,17 @@ export class TimeSyncService {
|
||||
}
|
||||
return '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an untrusted, in-path-controllable value (e.g. the server `Date` header) before
|
||||
* it is written to a log. Strips control characters (including CR/LF) to prevent log forging
|
||||
* if the log bus is ever forwarded to a backend store, and caps the length to bound noise.
|
||||
*
|
||||
* @param value raw value to sanitize
|
||||
* @returns a log-safe representation of the value
|
||||
*/
|
||||
private sanitizeForLog(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\u0000-\u001F\u007F]/g, ' ').slice(0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user