blob: a464fd4dcd89dacf2ecd93688d5d8bcd11523dd9 [file]
// 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 {assert} from 'chai';
import {beforeEach, describe, it} from 'mocha';
import sinon from 'sinon';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import {dispatchClickEvent, raf, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import {createViewFunctionStub} from '../../testing/ViewFunctionHelpers.js';
import type * as Buttons from '../../ui/components/buttons/buttons.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Application from './application.js';
const {urlString} = Platform.DevToolsPath;
const {DEFAULT_VIEW, PrivateVerificationTokensView} = Application.PrivateVerificationTokensView;
function makeToken(
overrides: Partial<Protocol.Storage.PrivateVerificationToken>&
Pick<Protocol.Storage.PrivateVerificationToken, 'id'|'issuerOrigin'>): Protocol.Storage.PrivateVerificationToken {
return {
keyId: 10,
expiration: 1700000000,
creationTime: 1690000000,
version: 1,
token: 'AQIDBA==',
...overrides,
};
}
function stubStorage(target: SDK.Target.Target, tokens: Protocol.Storage.PrivateVerificationToken[] = [],
configs: Protocol.Storage.PrivateVerificationTokensIssuerConfig[] = []) {
const getTokensStub = sinon.stub(target.storageAgent(), 'invoke_getPrivateVerificationTokens')
.resolves({tokens, getError: () => undefined});
const getConfigsStub = sinon.stub(target.storageAgent(), 'invoke_getPrivateVerificationTokensIssuerConfigs')
.resolves({configs, getError: () => undefined});
return {getTokensStub, getConfigsStub};
}
/**
* Returns the header passed to the EmptyWidget rendered in `container`, or null
* if no EmptyWidget is rendered.
*/
function getEmptyStateHeader(container: HTMLElement): string|null {
for (const element of container.querySelectorAll<HTMLElement>('devtools-widget')) {
const config = UI.Widget.widgetConfigs.get(element);
if (config?.widgetClass === UI.EmptyWidget.EmptyWidget) {
return config.widgetParams?.header ?? null;
}
}
return null;
}
async function createWidget() {
const viewStub = createViewFunctionStub(PrivateVerificationTokensView);
const component = new PrivateVerificationTokensView(undefined, viewStub);
renderElementIntoDOM(component);
await viewStub.nextInput;
return {viewStub, component};
}
describeWithEnvironment('PrivateVerificationTokensView', () => {
let target: SDK.Target.Target;
beforeEach(() => {
target = createTarget();
target.setInspectedURL(urlString`http://example.com`);
});
describe('presenter', () => {
it('includes issued and redeemable tokens for the current origin and omits unrelated tokens', async () => {
stubStorage(target,
[
makeToken({id: '1', issuerOrigin: 'https://issuer.org/'}),
makeToken({id: '2', issuerOrigin: 'http://example.com', keyId: 20, token: 'BQYHCA=='}),
makeToken({id: '3', issuerOrigin: 'https://unrelated.org', keyId: 30, token: 'CQoLDA=='}),
],
[
{issuerOrigin: 'https://issuer.org', redeemerOrigins: ['http://example.com']},
{issuerOrigin: 'http://example.com', redeemerOrigins: ['https://redeemer.org']},
{issuerOrigin: 'https://unrelated.org', redeemerOrigins: ['https://other-redeemer.org']},
]);
const {viewStub} = await createWidget();
assert.deepEqual(viewStub.input.tokens, [
{
id: '2',
issuerOrigin: urlString`http://example.com`,
isIssuer: true,
isRedeemer: false,
keyId: 20,
expiration: '2023-11-14T22:13:20.000Z',
creationTime: '2023-07-22T04:26:40.000Z',
version: 1,
token: 'BQYHCA==',
},
{
id: '1',
issuerOrigin: urlString`https://issuer.org`,
isIssuer: false,
isRedeemer: true,
keyId: 10,
expiration: '2023-11-14T22:13:20.000Z',
creationTime: '2023-07-22T04:26:40.000Z',
version: 1,
token: 'AQIDBA==',
},
]);
});
it('formats unset timestamps with a placeholder', async () => {
stubStorage(target, [makeToken({id: '1', issuerOrigin: 'http://example.com', expiration: 0, creationTime: 0})]);
const {viewStub} = await createWidget();
assert.strictEqual(viewStub.input.tokens[0].expiration, '—');
assert.strictEqual(viewStub.input.tokens[0].creationTime, '—');
});
it('passes empty tokens list when there are no matching private verification tokens', async () => {
stubStorage(target, [makeToken({id: '3', issuerOrigin: 'https://unrelated.org'})],
[{issuerOrigin: 'https://unrelated.org', redeemerOrigins: ['https://other-redeemer.org']}]);
const {viewStub} = await createWidget();
assert.deepEqual(viewStub.input.tokens, []);
});
it('passes empty tokens list when there is no primary page target', async () => {
stubStorage(target, [makeToken({id: '1', issuerOrigin: 'http://example.com'})]);
sinon.stub(SDK.TargetManager.TargetManager.instance(), 'primaryPageTarget').returns(null);
const {viewStub} = await createWidget();
assert.deepEqual(viewStub.input.tokens, []);
});
it('sorts tokens in natural ID order within the same issuer origin', async () => {
stubStorage(target, [
makeToken({id: '10', issuerOrigin: 'http://example.com'}),
makeToken({id: '2', issuerOrigin: 'http://example.com'}),
makeToken({id: '1', issuerOrigin: 'http://example.com'}),
]);
const {viewStub} = await createWidget();
assert.deepEqual(viewStub.input.tokens.map(t => t.id), ['1', '2', '10']);
});
it('updates selectedTokenId on select and deselect', async () => {
stubStorage(target, [
makeToken({id: '1', issuerOrigin: 'http://example.com'}),
makeToken({id: '2', issuerOrigin: 'http://example.com'}),
]);
const {viewStub} = await createWidget();
assert.isNull(viewStub.input.selectedTokenId);
viewStub.input.onSelect('2');
await viewStub.nextInput;
assert.strictEqual(viewStub.input.selectedTokenId, '2');
viewStub.input.onDeselect();
await viewStub.nextInput;
assert.isNull(viewStub.input.selectedTokenId);
});
it('calls clearPrivateVerificationTokens once per normalized issuer origin when onDeleteAll is invoked',
async () => {
const clearStub = sinon.stub(target.storageAgent(), 'invoke_clearPrivateVerificationTokens').resolves({
getError: () => undefined,
});
stubStorage(target,
[
makeToken({id: '1', issuerOrigin: 'https://issuer.org/'}),
makeToken({id: '2', issuerOrigin: 'https://issuer.org'}),
],
[{issuerOrigin: 'https://issuer.org', redeemerOrigins: ['http://example.com']}]);
const {viewStub} = await createWidget();
viewStub.input.onDeleteAll();
await viewStub.nextInput;
sinon.assert.calledOnceWithExactly(clearStub, {issuerOrigin: 'https://issuer.org'});
});
it('calls deletePrivateVerificationToken when onDeleteToken is invoked', async () => {
const deleteStub = sinon.stub(target.storageAgent(), 'invoke_deletePrivateVerificationToken').resolves({
getError: () => undefined,
});
stubStorage(target, [makeToken({id: '42', issuerOrigin: 'http://example.com'})]);
const {viewStub} = await createWidget();
viewStub.input.onDeleteToken('42');
await viewStub.nextInput;
sinon.assert.calledOnceWithExactly(deleteStub, {tokenId: '42'});
});
it('enables tracking when shown and disables tracking when hidden', async () => {
const trackingStub = sinon.stub(target.storageAgent(), 'invoke_setPrivateVerificationTokensTracking').resolves({
getError: () => undefined,
});
stubStorage(target);
const {component} = await createWidget();
sinon.assert.calledOnceWithExactly(trackingStub, {enable: true});
component.detach();
sinon.assert.calledWithExactly(trackingStub, {enable: false});
});
it('refreshes tokens when privateVerificationTokensUpdated event is received', async () => {
sinon.stub(target.storageAgent(), 'invoke_getPrivateVerificationTokensIssuerConfigs')
.resolves({configs: [], getError: () => undefined});
const getTokensStub = sinon.stub(target.storageAgent(), 'invoke_getPrivateVerificationTokens');
getTokensStub.onFirstCall().resolves({tokens: [], getError: () => undefined});
getTokensStub.onSecondCall().resolves(
{tokens: [makeToken({id: '99', issuerOrigin: 'http://example.com'})], getError: () => undefined});
const {viewStub} = await createWidget();
assert.deepEqual(viewStub.input.tokens, []);
const model = target.model(SDK.PrivateVerificationTokensModel.PrivateVerificationTokensModel);
assert.isNotNull(model);
model.privateVerificationTokensUpdated();
await viewStub.nextInput;
assert.lengthOf(viewStub.input.tokens, 1);
assert.strictEqual(viewStub.input.tokens[0].id, '99');
});
it('does not render stale tokens when hidden while a fetch is in flight', async () => {
sinon.stub(target.storageAgent(), 'invoke_getPrivateVerificationTokensIssuerConfigs')
.resolves({configs: [], getError: () => undefined});
const getTokensStub = sinon.stub(target.storageAgent(), 'invoke_getPrivateVerificationTokens');
getTokensStub.onFirstCall().resolves({tokens: [], getError: () => undefined});
const {promise: pendingTokens, resolve: resolveTokens} =
Promise.withResolvers<Protocol.Storage.GetPrivateVerificationTokensResponse>();
getTokensStub.onSecondCall().returns(pendingTokens);
const {viewStub, component} = await createWidget();
const model = target.model(SDK.PrivateVerificationTokensModel.PrivateVerificationTokensModel);
assert.isNotNull(model);
model.privateVerificationTokensUpdated();
await raf();
sinon.assert.calledTwice(getTokensStub);
component.detach();
resolveTokens({tokens: [makeToken({id: '99', issuerOrigin: 'http://example.com'})], getError: () => undefined});
await raf();
for (const call of viewStub.getCalls()) {
assert.deepEqual(call.args[0].tokens, []);
}
});
it('does not refetch tokens on selection changes', async () => {
const {getTokensStub, getConfigsStub} =
stubStorage(target, [makeToken({id: '1', issuerOrigin: 'http://example.com'})]);
const {viewStub} = await createWidget();
getTokensStub.resetHistory();
getConfigsStub.resetHistory();
viewStub.input.onSelect('1');
await viewStub.nextInput;
viewStub.input.onDeselect();
await viewStub.nextInput;
sinon.assert.notCalled(getTokensStub);
sinon.assert.notCalled(getConfigsStub);
assert.lengthOf(viewStub.input.tokens, 1);
});
});
describe('view', () => {
const defaultInput: Application.PrivateVerificationTokensView.ViewInput = {
tokens: [],
selectedTokenId: null,
onSelect: () => {},
onDeselect: () => {},
onDeleteAll: () => {},
onDeleteToken: () => {},
};
it('renders empty state and disables toolbar buttons when there are no tokens', () => {
const container = document.createElement('div');
renderElementIntoDOM(container);
DEFAULT_VIEW(defaultInput, undefined, container);
assert.isNull(container.querySelector('devtools-data-grid'));
assert.strictEqual(getEmptyStateHeader(container), 'No private verification tokens for this origin');
const clearAllButton = container.querySelector<Buttons.Button.Button>('.clear-all-button');
assert.isNotNull(clearAllButton);
assert.isTrue(clearAllButton.disabled);
const deleteSelectedButton = container.querySelector<Buttons.Button.Button>('.delete-selected-button');
assert.isNotNull(deleteSelectedButton);
assert.isTrue(deleteSelectedButton.disabled);
});
const tokens: Application.PrivateVerificationTokensView.TokenRow[] = [
{
id: '1',
issuerOrigin: urlString`https://issuer.org`,
isIssuer: false,
isRedeemer: true,
keyId: 10,
expiration: '2023-11-14T22:13:20.000Z',
creationTime: '2023-07-22T04:26:40.000Z',
version: 1,
token: 'AQIDBA==',
},
{
id: '2',
issuerOrigin: urlString`http://example.com`,
isIssuer: true,
isRedeemer: false,
keyId: 20,
expiration: '—',
creationTime: '—',
version: 1,
token: 'BQYHCA==',
},
];
async function renderWithTokens(overrides: Partial<Application.PrivateVerificationTokensView.ViewInput> = {}):
Promise<HTMLElement> {
const container = document.createElement('div');
renderElementIntoDOM(container);
DEFAULT_VIEW({...defaultInput, tokens, ...overrides}, undefined, container);
await raf();
return container;
}
it('renders a row for each token with its display values', async () => {
const container = await renderWithTokens();
assert.isNull(getEmptyStateHeader(container));
const rows = container.querySelectorAll('devtools-data-grid table tr');
assert.lengthOf(rows, 3);
assert.deepEqual(Array.from(rows[1].querySelectorAll('td')).map(td => td.textContent), [
'https://issuer.org',
'false',
'true',
'1',
'10',
'2023-11-14T22:13:20.000Z',
'2023-07-22T04:26:40.000Z',
'1',
'AQIDBA==',
]);
assert.deepEqual(Array.from(rows[2].querySelectorAll('td')).map(td => td.textContent), [
'http://example.com',
'true',
'false',
'2',
'20',
'—',
'—',
'1',
'BQYHCA==',
]);
});
it('calls onDeleteAll when the clear all button is clicked', async () => {
const onDeleteAll = sinon.spy();
const container = await renderWithTokens({onDeleteAll});
const clearAllButton = container.querySelector<Buttons.Button.Button>('.clear-all-button');
assert.isNotNull(clearAllButton);
assert.isFalse(clearAllButton.disabled);
dispatchClickEvent(clearAllButton);
sinon.assert.calledOnce(onDeleteAll);
});
it('calls onDeleteToken with the selected token when the delete selected button is clicked', async () => {
const onDeleteToken = sinon.spy();
const container = await renderWithTokens({selectedTokenId: '1', onDeleteToken});
const deleteSelectedButton = container.querySelector<Buttons.Button.Button>('.delete-selected-button');
assert.isNotNull(deleteSelectedButton);
assert.isFalse(deleteSelectedButton.disabled);
dispatchClickEvent(deleteSelectedButton);
sinon.assert.calledOnceWithExactly(onDeleteToken, '1');
});
it('marks only the selected token row as selected', async () => {
const container = await renderWithTokens({selectedTokenId: '1'});
const rows = container.querySelectorAll('devtools-data-grid table tr');
assert.isTrue(rows[1].hasAttribute('selected'));
assert.isFalse(rows[2].hasAttribute('selected'));
});
it('calls onSelect when a row is selected', async () => {
const onSelect = sinon.spy();
const container = await renderWithTokens({onSelect});
const rows = container.querySelectorAll('devtools-data-grid table tr');
rows[2].dispatchEvent(new CustomEvent('select'));
sinon.assert.calledOnceWithExactly(onSelect, '2');
});
});
});