blob: f52a433e6defe8978d4dc58f45efbb3f3df5df15 [file] [log] [blame]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../core/common/common.js';
import * as Protocol from '../../generated/protocol.js';
import {assertScreenshot, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {describeWithMockConnection} from '../../testing/MockConnection.js';
import {createViewFunctionStub} from '../../testing/ViewFunctionHelpers.js';
import * as Application from './application.js';
describeWithMockConnection('DeviceBoundSessionsView', () => {
const mockSessionId = 'session-id-123';
const mockSite = 'https://example.com';
let toLocaleStringStub: sinon.SinonStub;
beforeEach(async () => {
const original = Date.prototype.toLocaleString;
toLocaleStringStub = sinon.stub(Date.prototype, 'toLocaleString').callsFake(function(this: Date) {
return original.call(this, 'en-US', {timeZone: 'UTC'});
});
});
afterEach(() => {
toLocaleStringStub.restore();
});
function createMockSession(): Application.DeviceBoundSessionsModel.SessionAndEvents {
return {
eventsById: new Map(),
isSessionTerminated: false,
hasErrors: false,
session: {
key: {site: mockSite, id: mockSessionId},
refreshUrl: 'https://example.com/refresh',
expiryDate: 1700000000,
allowedRefreshInitiators: ['example.com', '*.example.com', 'site-embedding-example.com'],
inclusionRules: {
origin: 'https://example.com',
includeSite: true,
urlRules: [
{
ruleType: Protocol.Network.DeviceBoundSessionUrlRuleRuleType.Include,
hostPattern: '*.example.com',
pathPrefix: '/path'
},
{
ruleType: Protocol.Network.DeviceBoundSessionUrlRuleRuleType.Exclude,
hostPattern: 'example.com',
pathPrefix: '/untrusted'
},
]
},
cookieCravings: [
{
name: 'session_token',
domain: 'example.com',
path: '/',
secure: false,
httpOnly: false,
sameSite: Protocol.Network.CookieSameSite.Strict
},
{name: 'session_token2', domain: '.example.com', path: '/path', secure: false, httpOnly: false},
],
cachedChallenge: 'test-challenge',
}
};
}
function createMockSessionAndEvents(): Application.DeviceBoundSessionsModel.SessionAndEvents {
const sessionAndEvents = createMockSession();
const dates = [
new Date('2026-01-01T10:00:00.000Z'), new Date('2026-01-02T10:00:00.000Z'), new Date('2026-01-03T10:00:00.000Z'),
new Date('2026-01-04T10:00:00.000Z')
];
sessionAndEvents.eventsById.set('creation-full', {
event: {
eventId: 'creation-full' as Protocol.Network.DeviceBoundSessionEventId,
site: mockSite,
sessionId: mockSessionId,
succeeded: true,
creationEventDetails:
{fetchResult: Protocol.Network.DeviceBoundSessionFetchResult.Success, newSession: sessionAndEvents.session}
},
timestamp: dates[0]
});
sessionAndEvents.eventsById.set('creation-min', {
event: {
eventId: 'creation-min' as Protocol.Network.DeviceBoundSessionEventId,
site: mockSite,
sessionId: mockSessionId,
succeeded: false,
creationEventDetails: {fetchResult: Protocol.Network.DeviceBoundSessionFetchResult.KeyError}
},
timestamp: dates[0]
});
sessionAndEvents.eventsById.set('refresh-full', {
event: {
eventId: 'event-full' as Protocol.Network.DeviceBoundSessionEventId,
site: mockSite,
sessionId: mockSessionId,
succeeded: true,
refreshEventDetails: {
refreshResult: Protocol.Network.RefreshEventDetailsRefreshResult.Refreshed,
wasFullyProactiveRefresh: false,
fetchResult: Protocol.Network.DeviceBoundSessionFetchResult.Success,
newSession: sessionAndEvents.session
}
},
timestamp: dates[1]
});
sessionAndEvents.eventsById.set('refresh-min', {
event: {
eventId: 'event-min' as Protocol.Network.DeviceBoundSessionEventId,
site: mockSite,
sessionId: mockSessionId,
succeeded: false,
refreshEventDetails: {
refreshResult: Protocol.Network.RefreshEventDetailsRefreshResult.FatalError,
wasFullyProactiveRefresh: true
}
},
timestamp: dates[1]
});
sessionAndEvents.eventsById.set('challenge', {
event: {
succeeded: false,
eventId: 'challlenge' as Protocol.Network.DeviceBoundSessionEventId,
site: mockSite,
sessionId: mockSessionId,
challengeEventDetails:
{challenge: 'challenge', challengeResult: Protocol.Network.ChallengeEventDetailsChallengeResult.Success}
},
timestamp: dates[2]
});
sessionAndEvents.eventsById.set('termination', {
event: {
succeeded: false,
eventId: 'termination' as Protocol.Network.DeviceBoundSessionEventId,
sessionId: mockSessionId,
site: mockSite,
terminationEventDetails: {deletionReason: Protocol.Network.TerminationEventDetailsDeletionReason.Expired}
},
timestamp: dates[3]
});
return sessionAndEvents;
}
function createSetting() {
return Common.Settings.Settings.instance().createSetting('device-bound-sessions-preserve-log', false);
}
it('fetches session details from the model and passes them to the view', async () => {
const viewFunction = createViewFunctionStub(Application.DeviceBoundSessionsView.DeviceBoundSessionsView);
const view = new Application.DeviceBoundSessionsView.DeviceBoundSessionsView(viewFunction);
const mockData = createMockSession();
const mockModel = {
getSession: sinon.stub().returns(mockData),
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
getPreserveLogSetting: sinon.stub().returns(createSetting()),
} as unknown as Application.DeviceBoundSessionsModel.DeviceBoundSessionsModel;
view.showSession(mockModel, mockSite, mockSessionId);
const {sessionAndEvents, preserveLogSetting} = viewFunction.input;
assert.deepEqual(sessionAndEvents, mockData);
assert.exists(preserveLogSetting);
});
it('updates the view when the model triggers an event', async () => {
const viewFunction = createViewFunctionStub(Application.DeviceBoundSessionsView.DeviceBoundSessionsView);
const view = new Application.DeviceBoundSessionsView.DeviceBoundSessionsView(viewFunction);
const mockData = createMockSession();
const addEventListenerStub = sinon.stub();
const getSessionStub = sinon.stub().returns(mockData);
const mockModel = {
getSession: getSessionStub,
addEventListener: addEventListenerStub,
removeEventListener: sinon.stub(),
getPreserveLogSetting: sinon.stub().returns(createSetting()),
} as unknown as Application.DeviceBoundSessionsModel.DeviceBoundSessionsModel;
view.showSession(mockModel, mockSite, mockSessionId);
assert.deepEqual(viewFunction.input.sessionAndEvents, mockData);
assert.exists(mockData.session);
const newMockData = {
...mockData,
session: {
...mockData.session,
refreshUrl: 'https://example.com/updatedRefresh',
}
};
getSessionStub.returns(newMockData);
const eventHandler = addEventListenerStub.firstCall.args[1];
eventHandler.call(view);
assert.deepEqual(viewFunction.input.sessionAndEvents, newMockData);
sinon.assert.calledTwice(getSessionStub);
});
it('shows default view with title and description', async () => {
const viewFunction = createViewFunctionStub(Application.DeviceBoundSessionsView.DeviceBoundSessionsView);
const view = new Application.DeviceBoundSessionsView.DeviceBoundSessionsView(viewFunction);
const mockModel = {
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
getPreserveLogSetting: sinon.stub().returns(createSetting()),
} as unknown as Application.DeviceBoundSessionsModel.DeviceBoundSessionsModel;
view.showDefault(mockModel, 'Default Title', 'Default Description');
const {defaultTitle, defaultDescription, sessionAndEvents} = viewFunction.input;
assert.strictEqual(defaultTitle, 'Default Title');
assert.strictEqual(defaultDescription, 'Default Description');
assert.isUndefined(sessionAndEvents);
});
it('switches between session view and default view correctly', async () => {
const viewFunction = createViewFunctionStub(Application.DeviceBoundSessionsView.DeviceBoundSessionsView);
const view = new Application.DeviceBoundSessionsView.DeviceBoundSessionsView(viewFunction);
const mockData = createMockSession();
const mockModel = {
getSession: sinon.stub().returns(mockData),
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
getPreserveLogSetting: sinon.stub().returns(createSetting()),
} as unknown as Application.DeviceBoundSessionsModel.DeviceBoundSessionsModel;
view.showSession(mockModel, mockSite, mockSessionId);
assert.exists(viewFunction.input.sessionAndEvents);
assert.isUndefined(viewFunction.input.defaultTitle);
assert.isUndefined(viewFunction.input.defaultDescription);
view.showDefault(mockModel, 'Default Title', 'Default Description');
assert.isUndefined(viewFunction.input.sessionAndEvents);
assert.strictEqual(viewFunction.input.defaultTitle, 'Default Title');
assert.strictEqual(viewFunction.input.defaultDescription, 'Default Description');
view.showSession(mockModel, mockSite, mockSessionId);
assert.exists(viewFunction.input.sessionAndEvents);
assert.isUndefined(viewFunction.input.defaultTitle);
assert.isUndefined(viewFunction.input.defaultDescription);
});
it('passes the selected event to the view when onEventRowSelected is called', () => {
const viewFunction = sinon.stub();
const component = new Application.DeviceBoundSessionsView.DeviceBoundSessionsView(viewFunction);
const mockModel = {
getSession: sinon.stub().returns({session: null, eventsById: new Map()}),
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
getPreserveLogSetting: sinon.stub().returns(createSetting()),
} as unknown as Application.DeviceBoundSessionsModel.DeviceBoundSessionsModel;
const mockEvent = {creationEventDetails: {fetchResult: Protocol.Network.DeviceBoundSessionFetchResult.Success}} as
Protocol.Network.DeviceBoundSessionEventOccurredEvent;
component.showSession(mockModel, 'example.com');
sinon.assert.calledOnce(viewFunction);
const firstCallInput = viewFunction.lastCall.args[0];
assert.isUndefined(firstCallInput.selectedEvent);
assert.isFunction(firstCallInput.onEventRowSelected);
firstCallInput.onEventRowSelected(mockEvent);
sinon.assert.calledTwice(viewFunction);
const secondCallInput = viewFunction.lastCall.args[0];
assert.strictEqual(secondCallInput.selectedEvent, mockEvent);
});
it('only deselects the event grid row when selectedEvent is undefined', async () => {
const sessionAndEvents = createMockSessionAndEvents();
const eventWrapper1 = sessionAndEvents.eventsById.get('creation-full');
const eventWrapper2 = sessionAndEvents.eventsById.get('refresh-full');
assert.exists(eventWrapper1);
assert.exists(eventWrapper2);
const viewInput = {
sessionAndEvents,
preserveLogSetting: createSetting(),
selectedEvent: undefined,
onEventRowSelected: () => {},
};
const target = document.createElement('div');
renderElementIntoDOM(target);
Application.DeviceBoundSessionsView.DEFAULT_VIEW(viewInput, {}, target);
const dataGrid = customElements.get('devtools-data-grid');
assert.exists(dataGrid);
assert.isFunction(dataGrid.prototype.deselectRow);
const deselectSpy = sinon.spy(dataGrid.prototype, 'deselectRow');
Application.DeviceBoundSessionsView.DEFAULT_VIEW({...viewInput, selectedEvent: eventWrapper1.event}, {}, target);
sinon.assert.notCalled(deselectSpy);
Application.DeviceBoundSessionsView.DEFAULT_VIEW({...viewInput, selectedEvent: eventWrapper2.event}, {}, target);
sinon.assert.notCalled(deselectSpy);
Application.DeviceBoundSessionsView.DEFAULT_VIEW(viewInput, {}, target);
sinon.assert.calledOnce(deselectSpy);
deselectSpy.restore();
});
it('renders session correctly', async () => {
const viewInput = {
sessionAndEvents: createMockSession(),
preserveLogSetting: createSetting(),
selectedEvent: undefined,
onEventRowSelected: () => {},
};
const target = document.createElement('div');
target.style.width = '800px';
target.style.height = '800px';
target.style.display = 'flex';
target.style.flexDirection = 'column';
renderElementIntoDOM(target);
Application.DeviceBoundSessionsView.DEFAULT_VIEW(viewInput, {}, target);
await assertScreenshot('application/DeviceBoundSessionsView/session.png');
});
it('renders events correctly', async () => {
const sessionAndEvents: Application.DeviceBoundSessionsModel.SessionAndEvents = {
eventsById: new Map(),
isSessionTerminated: false,
hasErrors: false,
session: undefined,
};
const date = new Date('2026-01-01T10:00:00.000Z');
sessionAndEvents.eventsById.set('event-1', {
event: {
creationEventDetails: {fetchResult: Protocol.Network.DeviceBoundSessionFetchResult.Success},
succeeded: false,
eventId: 'event-1' as Protocol.Network.DeviceBoundSessionEventId,
site: mockSite,
},
timestamp: date
});
const viewInput = {
sessionAndEvents,
preserveLogSetting: createSetting(),
selectedEvent: undefined,
onEventRowSelected: () => {},
};
const target = document.createElement('div');
target.style.width = '800px';
target.style.height = '400px';
target.style.display = 'flex';
target.style.flexDirection = 'column';
renderElementIntoDOM(target);
Application.DeviceBoundSessionsView.DEFAULT_VIEW(viewInput, {}, target);
await assertScreenshot('application/DeviceBoundSessionsView/events.png');
});
it('renders session and events correctly', async () => {
const sessionAndEvents = createMockSessionAndEvents();
const viewInput = {
sessionAndEvents,
preserveLogSetting: createSetting(),
selectedEvent: undefined,
onEventRowSelected: () => {},
};
const target = document.createElement('div');
target.style.width = '800px';
target.style.height = '850px';
target.style.display = 'flex';
target.style.flexDirection = 'column';
renderElementIntoDOM(target);
Application.DeviceBoundSessionsView.DEFAULT_VIEW(viewInput, {}, target);
await assertScreenshot('application/DeviceBoundSessionsView/session_and_events.png');
});
it('renders the default view correctly', async () => {
const viewInput = {
defaultTitle: 'Default Title',
defaultDescription: 'Default Description',
preserveLogSetting: createSetting(),
};
const target = document.createElement('div');
target.style.width = '300px';
target.style.height = '300px';
target.style.display = 'flex';
target.style.flexDirection = 'column';
renderElementIntoDOM(target);
Application.DeviceBoundSessionsView.DEFAULT_VIEW(viewInput, {}, target);
await assertScreenshot('application/DeviceBoundSessionsView/default_view.png');
});
it('renders event details default view correctly', async () => {
const sessionAndEvents = createMockSessionAndEvents();
const selectedEvent = sessionAndEvents.eventsById.get('creation-min');
assert.isDefined(selectedEvent);
const viewInput = {
sessionAndEvents,
preserveLogSetting: createSetting(),
selectedEvent: selectedEvent.event,
onEventRowSelected: () => {},
};
const target = document.createElement('div');
target.style.width = '800px';
target.style.height = '800px';
target.style.display = 'flex';
target.style.flexDirection = 'column';
renderElementIntoDOM(target);
Application.DeviceBoundSessionsView.DEFAULT_VIEW(viewInput, {}, target);
await assertScreenshot(`application/DeviceBoundSessionsView/session_and_events_and_event_details.png`);
});
// These just display the event details portion because otherwise the height
// is cropped and not all details are shown.
async function runEventDetailsTest(eventId: string, screenshotFileName: string) {
const sessionAndEvents = createMockSessionAndEvents();
const selectedEvent = sessionAndEvents.eventsById.get(eventId);
assert.isDefined(selectedEvent);
const viewInput = {
sessionAndEvents,
preserveLogSetting: createSetting(),
selectedEvent: selectedEvent.event,
onEventRowSelected: () => {},
};
const target = document.createElement('div');
Application.DeviceBoundSessionsView.DEFAULT_VIEW(viewInput, {}, target);
const eventDetailContents = target.querySelector('.device-bound-session-sidebar');
assert.isNotNull(eventDetailContents);
renderElementIntoDOM(eventDetailContents);
await assertScreenshot(screenshotFileName);
}
it('renders creation full details correctly', async () => {
await runEventDetailsTest('creation-full', 'application/DeviceBoundSessionsView/creation_full_event_details.png');
});
it('renders creation minimal event details correctly', async () => {
await runEventDetailsTest('creation-min', 'application/DeviceBoundSessionsView/creation_min_event_details.png');
});
it('renders refresh full details correctly', async () => {
await runEventDetailsTest('refresh-full', 'application/DeviceBoundSessionsView/refresh_full_event_details.png');
});
it('renders refresh minimal details correctly', async () => {
await runEventDetailsTest('refresh-min', 'application/DeviceBoundSessionsView/refresh_min_event_details.png');
});
it('renders challenge event details correctly', async () => {
await runEventDetailsTest('challenge', 'application/DeviceBoundSessionsView/challenge_event_details.png');
});
it('renders termination event details correctly', async () => {
await runEventDetailsTest('termination', 'application/DeviceBoundSessionsView/termination_event_details.png');
});
});