기여 목록 / 8324787
[security] Migrate SecurityOriginView certificate section to Lit
DevToolsrefactordevtools/devtools-frontendMerged
SecurityOriginView의 certificate section을 Lit으로 전환했습니다. certificate details 표시와
certificate viewer를 여는 동작의 테스트를 추가했습니다.
문제 설명
certificate section은 기존 DOM 생성 코드로 구성하고, SAN 목록만 별도로 Lit으로
렌더링하고 있었습니다. 인증서 정보와 SAN, 상세 보기 버튼을 하나의 Lit
렌더링 흐름으로 전환하는 작업입니다.
해결 내용
buildCertificateDetailsRows()와renderCertificateSection()에서 인증서 제목과 상세 정보 표를 만들도록 했습니다. Subject, SAN, 유효 기간, Issuer, 상세 보기 버튼의 순서와 제목의 접근성 속성을 유지했습니다.- 표의 값에 문자열뿐 아니라 Lit 템플릿도 사용할 수 있도록 확장하고, SAN과 상세 보기 버튼을 템플릿으로 포함했습니다.
#createCertificateSection()에서 SAN의 접힘 상태를 관리하고, 버튼 클릭 시certificate section을 다시 렌더링하도록 했습니다. 기존#createSanDiv()는 제거했습니다.- 인증서 조회와 뷰어 호출을
#showCertificateViewer로 옮겼습니다. 조회 결과가 비어 있으면 뷰어를 호출하지 않는 동작을 유지했습니다. certificate section의 단위 테스트 5개와 테스트에 필요한 Host 의존성을 추가했습니다.
전체 코드 diff는 다음과 같습니다.
diff --git a/front_end/panels/security/BUILD.gn b/front_end/panels/security/BUILD.gn
index a5e7ef9..f1c63e8 100644
--- a/front_end/panels/security/BUILD.gn
+++ b/front_end/panels/security/BUILD.gn
@@ -79,6 +79,7 @@
ts_deps = [
":bundle",
"../../core/common:bundle",
+ "../../core/host:bundle",
"../../core/platform:bundle",
"../../core/sdk:bundle",
"../../generated",
diff --git a/front_end/panels/security/SecurityPanel.test.ts b/front_end/panels/security/SecurityPanel.test.ts
index e654871..0b13e71 100644
--- a/front_end/panels/security/SecurityPanel.test.ts
+++ b/front_end/panels/security/SecurityPanel.test.ts
@@ -6,11 +6,13 @@
import sinon from 'sinon';
import * as Common from '../../core/common/common.js';
+import * as Host from '../../core/host/host.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import {doubleRaf, querySelectorErrorOnMissing, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
+import {expectCall} from '../../testing/ExpectStubCall.js';
import {createNetworkRequest} from '../../testing/NetworkRequestHelpers.js';
import {getMainFrame, navigate} from '../../testing/ResourceTreeHelpers.js';
import * as NetworkForward from '../network/forward/forward.js';
@@ -264,6 +266,81 @@
});
});
+ describe('certificate section', () => {
+ it('renders the heading', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState());
+
+ const certificateSection = querySelectorErrorOnMissing(view.element, '.certificate-section');
+ const heading = querySelectorErrorOnMissing(certificateSection, '.origin-view-section-title');
+ assert.strictEqual(heading.textContent, 'Certificate');
+ assert.strictEqual(heading.getAttribute('role'), 'heading');
+ assert.strictEqual(heading.getAttribute('aria-level'), '2');
+ });
+
+ it('does not render without security details', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, {
+ securityState: Protocol.Security.SecurityState.Secure,
+ securityDetails: null,
+ loadedFromCache: false,
+ });
+
+ assert.notExists(view.element.querySelector('.certificate-section'));
+ });
+
+ it('renders all certificate details in order', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState({
+ subjectName: 'example.com',
+ sanList: ['san.example.com'],
+ validFrom: 0,
+ validTo: 86400,
+ issuer: 'Test CA',
+ }));
+ const certificateSection = querySelectorErrorOnMissing(view.element, '.certificate-section');
+
+ assert.deepEqual(getDetailsTableRows(certificateSection), [
+ ['Subject', 'example.com'],
+ ['SAN', 'san.example.com'],
+ ['Valid from', 'Thu, 01 Jan 1970 00:00:00 GMT'],
+ ['Valid until', 'Fri, 02 Jan 1970 00:00:00 GMT'],
+ ['Issuer', 'Test CA'],
+ ['', 'Open full certificate details'],
+ ]);
+ });
+
+ it('opens the certificate viewer', async () => {
+ const getCertificate = sinon.stub(SDK.NetworkManager.MultitargetNetworkManager.instance(), 'getCertificate')
+ .resolves(['certificate']);
+ const showCertificateViewer =
+ sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'showCertificateViewer');
+ const showCertificateViewerCall = expectCall(showCertificateViewer);
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState());
+
+ const certificateSection = querySelectorErrorOnMissing(view.element, '.certificate-section');
+ const certificateButton = querySelectorErrorOnMissing(certificateSection, 'devtools-button.origin-button');
+ certificateButton.click();
+ await showCertificateViewerCall;
+
+ sinon.assert.calledOnceWithExactly(getCertificate, 'https://foo.bar');
+ sinon.assert.calledOnceWithExactly(showCertificateViewer, ['certificate']);
+ });
+
+ it('does not open the certificate viewer when no certificates are returned', async () => {
+ const getCertificate =
+ sinon.stub(SDK.NetworkManager.MultitargetNetworkManager.instance(), 'getCertificate').resolves([]);
+ const showCertificateViewer =
+ sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'showCertificateViewer');
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState());
+
+ const certificateSection = querySelectorErrorOnMissing(view.element, '.certificate-section');
+ const certificateButton = querySelectorErrorOnMissing(certificateSection, 'devtools-button.origin-button');
+ certificateButton.click();
+ await getCertificate.firstCall.returnValue;
+
+ sinon.assert.calledOnceWithExactly(getCertificate, 'https://foo.bar');
+ sinon.assert.notCalled(showCertificateViewer);
+ });
+ });
+
it('renders an empty SAN', () => {
const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState());
diff --git a/front_end/panels/security/SecurityPanel.ts b/front_end/panels/security/SecurityPanel.ts
index 0ed93e9..28f4c7a 100644
--- a/front_end/panels/security/SecurityPanel.ts
+++ b/front_end/panels/security/SecurityPanel.ts
@@ -620,18 +620,6 @@
return securityPanelInstance;
}
- static createCertificateViewerButtonForOrigin(text: string, origin: string): Element {
- const certificateButton = UI.UIUtils.createTextButton(text, async (e: Event) => {
- e.consume();
- const names = await SDK.NetworkManager.MultitargetNetworkManager.instance().getCertificate(origin);
- if (names.length > 0) {
- Host.InspectorFrontendHost.InspectorFrontendHostInstance.showCertificateViewer(names);
- }
- }, {className: 'origin-button', jslogContext: 'security.view-certificate-for-origin', title: text});
- UI.ARIAUtils.markAsButton(certificateButton);
- return certificateButton;
- }
-
static createCertificateViewerButtonForCert(text: string, names: string[]): Element {
const certificateButton = UI.UIUtils.createTextButton(text, e => {
e.consume();
@@ -1319,12 +1307,16 @@
const SAN_NUM_SHOWN_WHEN_TRUNCATED = 2;
-function renderSan(sanList: string[], isSanListTruncatable: boolean, isSanListTruncated: boolean,
- onToggleTruncation: () => void): TemplateResult {
+function isSanListTruncatable(sanList: string[]): boolean {
+ return sanList.length > SAN_NUM_SHOWN_WHEN_TRUNCATED + 1;
+}
+
+function renderSan(sanList: string[], isSanListTruncated: boolean, onToggleTruncation: () => void): TemplateResult {
if (sanList.length === 0) {
return html`<div class="san empty-san">${i18nString(UIStrings.na)}</div>`;
}
+ const isTruncatable = isSanListTruncatable(sanList);
const toggleButtonText =
isSanListTruncated ? i18nString(UIStrings.showMoreSTotal, {PH1: sanList.length}) : i18nString(UIStrings.showLess);
@@ -1335,10 +1327,10 @@
return html`
<span class=${Directives.classMap({
'san-entry': true,
- 'truncated-entry': isSanListTruncatable && index >= SAN_NUM_SHOWN_WHEN_TRUNCATED,
+ 'truncated-entry': isTruncatable && index >= SAN_NUM_SHOWN_WHEN_TRUNCATED,
})}>${san}</span>`;
})}
- ${isSanListTruncatable ? html`
+ ${isTruncatable ? html`
<devtools-button
.variant=${Buttons.Button.Variant.OUTLINED}
.accessibleLabel=${toggleButtonText}
@@ -1351,8 +1343,8 @@
}
interface DetailsTableRow {
- key: string;
- value: string;
+ key?: string;
+ value: string|TemplateResult;
}
function renderDetailsTable(rows: DetailsTableRow[]): TemplateResult {
@@ -1361,7 +1353,7 @@
<table class="details-table">
${rows.map(row => html`
<tr class="details-table-row">
- <td>${row.key}</td>
+ <td>${row.key ?? nothing}</td>
<td>${row.value}</td>
</tr>`)}
</table>`;
@@ -1451,6 +1443,55 @@
// clang-format on
}
+interface CertificateSectionInput {
+ securityDetails: Protocol.Network.SecurityDetails;
+ isSanListTruncated: boolean;
+ onToggleSanTruncation: () => void;
+ onViewCertificate: (event: Event) => void;
+}
+
+function buildCertificateDetailsRows(input: CertificateSectionInput): DetailsTableRow[] {
+ const {
+ securityDetails,
+ isSanListTruncated,
+ onToggleSanTruncation,
+ onViewCertificate,
+ } = input;
+ const certificateButtonText = i18nString(UIStrings.openFullCertificateDetails);
+
+ // clang-format off
+ return [
+ {key: i18nString(UIStrings.subject), value: securityDetails.subjectName},
+ {
+ key: i18n.i18n.lockedString('SAN'),
+ value: renderSan(securityDetails.sanList, isSanListTruncated, onToggleSanTruncation),
+ },
+ {key: i18nString(UIStrings.validFrom), value: new Date(1000 * securityDetails.validFrom).toUTCString()},
+ {key: i18nString(UIStrings.validUntil), value: new Date(1000 * securityDetails.validTo).toUTCString()},
+ {key: i18nString(UIStrings.issuer), value: securityDetails.issuer},
+ {
+ value: html`
+ <devtools-button
+ class="origin-button"
+ title=${certificateButtonText}
+ .variant=${Buttons.Button.Variant.OUTLINED}
+ .jslogContext=${'security.view-certificate-for-origin'}
+ @click=${onViewCertificate}>${certificateButtonText}</devtools-button>`,
+ },
+ ];
+ // clang-format on
+}
+
+function renderCertificateSection(input: CertificateSectionInput): TemplateResult {
+ const rows = buildCertificateDetailsRows(input);
+
+ // clang-format off
+ return html`
+ <div class="origin-view-section-title" role="heading" aria-level="2">${i18nString(UIStrings.certificate)}</div>
+ ${renderDetailsTable(rows)}`;
+ // clang-format on
+}
+
export class SecurityOriginView extends UI.Widget.VBox {
readonly #origin: Platform.DevToolsPath.UrlString;
readonly #titleSection: HTMLElement;
@@ -1471,11 +1512,7 @@
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
render(renderConnectionSection(originState.securityDetails), connectionSection);
- // Create the certificate section outside the callback, so that it appears in the right place.
- const certificateSection = this.element.createChild('div', 'origin-view-section');
- const certificateDiv = certificateSection.createChild('div', 'origin-view-section-title');
- certificateDiv.textContent = i18nString(UIStrings.certificate);
- UI.ARIAUtils.markAsHeading(certificateDiv, 2);
+ this.#createCertificateSection(originState.securityDetails);
const sctListLength = originState.securityDetails.signedCertificateTimestampList.length;
const ctCompliance = originState.securityDetails.certificateTransparencyCompliance;
@@ -1488,23 +1525,6 @@
UI.ARIAUtils.markAsHeading(sctDiv, 2);
}
- const sanDiv = this.#createSanDiv(originState.securityDetails.sanList);
- const validFromString = new Date(1000 * originState.securityDetails.validFrom).toUTCString();
- const validUntilString = new Date(1000 * originState.securityDetails.validTo).toUTCString();
-
- const table = new SecurityDetailsTable();
- certificateSection.appendChild(table.element());
- table.addRow(i18nString(UIStrings.subject), originState.securityDetails.subjectName);
- table.addRow(i18n.i18n.lockedString('SAN'), sanDiv);
- table.addRow(i18nString(UIStrings.validFrom), validFromString);
- table.addRow(i18nString(UIStrings.validUntil), validUntilString);
- table.addRow(i18nString(UIStrings.issuer), originState.securityDetails.issuer);
-
- table.addRow(
- '',
- SecurityPanel.createCertificateViewerButtonForOrigin(
- i18nString(UIStrings.openFullCertificateDetails), origin));
-
if (!sctSection) {
return;
}
@@ -1600,25 +1620,34 @@
}
}
- #createSanDiv(sanList: string[]): Element {
- const container = document.createElement('div');
- const isSanListTruncatable = sanList.length > SAN_NUM_SHOWN_WHEN_TRUNCATED + 1;
- let isSanListTruncated = isSanListTruncatable;
-
- const onToggleTruncation = (): void => {
+ #createCertificateSection(securityDetails: Protocol.Network.SecurityDetails): void {
+ const certificateSection = this.element.createChild('div', 'origin-view-section certificate-section');
+ let isSanListTruncated = isSanListTruncatable(securityDetails.sanList);
+ const onToggleSanTruncation = (): void => {
isSanListTruncated = !isSanListTruncated;
- updateSan();
+ updateCertificateSection();
};
-
- const updateSan = (): void => {
+ const updateCertificateSection = (): void => {
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
- render(renderSan(sanList, isSanListTruncatable, isSanListTruncated, onToggleTruncation), container);
+ render(renderCertificateSection({
+ securityDetails,
+ isSanListTruncated,
+ onToggleSanTruncation,
+ onViewCertificate: this.#showCertificateViewer,
+ }),
+ certificateSection);
};
-
- updateSan();
- return container;
+ updateCertificateSection();
}
+ #showCertificateViewer = async(event: Event): Promise<void> => {
+ event.consume();
+ const names = await SDK.NetworkManager.MultitargetNetworkManager.instance().getCertificate(this.#origin);
+ if (names.length > 0) {
+ Host.InspectorFrontendHost.InspectorFrontendHostInstance.showCertificateViewer(names);
+ }
+ };
+
setSecurityState(newSecurityState: Protocol.Security.SecurityState): void {
this.#renderTitleSection(newSecurityState);
}테스트 방법
SecurityPanel.test.ts에 다음 다섯 단위 테스트를 추가했습니다.
- 제목과
role="heading",aria-level="2"확인 security details가 없을 때certificate section생략- 인증서 정보의 표시 순서와 유효 기간의 UTC 문자열 확인
- 상세 보기 버튼 클릭 후 Origin으로 인증서를 조회하고 결과를 뷰어에 전달하는지 확인
- 조회 결과가 빈 배열이면 비동기 조회 완료 후에도 뷰어를 호출하지 않는지 확인
리뷰 중 Chromium 보안 웹 테스트 2개의 실패가 보고되어, CL 8398154에서 기대값을 임시 조정했습니다. 이후 CQ 실행이 통과했으며, 2026년 9월 14일(한국 시간) DevTools main에 병합되었습니다.
배운 점
- 일부만 Lit으로 전환된 영역을 통합할 때는 기존 상태 관리와 클릭 동작도 새 렌더링 흐름에 연결해야 합니다.
- 비동기 동작이 발생하지 않는 경우를 검증할 때도 선행 비동기 작업이 끝난 뒤 호출 여부를 확인해야 합니다.