Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions e2e/docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -749,8 +749,21 @@ beside a second CSR that must come through untouched. The wizard is driven with
its defaults (no profile, RSA 2048, SHA256, no extensions) and only the subject
filled in. The row tag is `csr-<name>`. The card lists four CSRs before its
pager, so each test fails at the start if its rows would not fit. Importing a
CSR, profiles, extensions, Edit, Download, ACME certificates, the Certificates
card itself and ACME DNS authenticators are not covered.
CSR, profiles, extensions, the CSR card's Edit and Download, ACME certificates
and ACME DNS authenticators are not covered.

The Certificates card is in `tests/certificates-import.e2e.ts`: importing a
certificate with its private key, editing one's name and "Add to trusted store",
View/Download Certificate and View/Download Key from the edit panel's Actions
menu, and deleting one from its row. Every certificate is one checked-in,
test-only self-signed certificate and key (`fixtures/test-certificate.ts`, valid
until 2076), because Node's crypto cannot sign an X.509 certificate; middleware
holds certificates unique by name only, so the same PEM goes under several
names. Each save is read back through `certificate.query`, including the PEM, and
edit and delete each run beside a second certificate that must not change. The
row tag is `cert-<name>`, and the card has the same four-row limit as the CSR
card. Importing with a passphrase, the Download action and the trusted store's
effect on the system are not covered.

The Portals, Initiators and Authorized Access tabs of the full iSCSI page, and
its Global Target Configuration, are in `tests/iscsi-config.e2e.ts`, reached
Expand Down
57 changes: 55 additions & 2 deletions e2e/fixtures/certificates.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/**
* Certificate signing request preconditions, cleanup and readback, over the API.
* Certificate and certificate signing request preconditions, cleanup and
* readback, over the API.
*
* Keyed on the name, which middleware holds unique among certificates and CSRs
* alike, and which the CSR card builds every id on a row from. Creating and
* alike, and which both cards build every id on a row from. Creating and
* deleting one are jobs.
*/
import type { CallResponse } from '@truenas/api-client';
import { firstValueFrom, timeout } from 'rxjs';
import { testCertificatePem, testPrivateKeyPem } from './test-certificate';
import type { E2eApiClient, E2eApiDirectory } from '../support/api/client';
import { runJob } from '../support/jobs';
import { readTimeoutMs, slowCallTimeoutMs } from '../support/timeouts';
Expand All @@ -29,6 +31,27 @@ export async function findCertificate(client: E2eApiClient, name: string): Promi
return certificate as CertificateRecord | undefined;
}

/** The certificate or CSR with this id, or undefined — for following one across a rename. */
export async function findCertificateById(client: E2eApiClient, id: number): Promise<CertificateRecord | undefined> {
const [certificate] = await firstValueFrom(
client.api.query('certificate.query', [['id', '=', id]]).pipe(timeout(readTimeoutMs)),
);

return certificate as CertificateRecord | undefined;
}

/**
* How many certificates the appliance holds, by the rule the Certificates page
* itself uses to put a row on the Certificates card: a `certificate` that is not null.
*/
export async function countCertificates(client: E2eApiClient): Promise<number> {
const certificates = await firstValueFrom(
client.api.query('certificate.query', []).pipe(timeout(readTimeoutMs)),
);

return certificates.filter((certificate) => certificate.certificate !== null).length;
}

/**
* How many CSRs the appliance holds, by the rule the Certificates page itself uses
* to put a row on the CSR card: a `CSR` that is not null.
Expand Down Expand Up @@ -59,6 +82,36 @@ export async function ensureCertificateAbsent(client: E2eApiClient, name: string
);
}

/**
* Imports the checked-in test certificate and its key under this name, if
* nothing has this name already.
*
* For the certificate an edit, a view or a delete acts on, and for a bystander.
* Never for the certificate under test in an import — that one goes through the
* form. Middleware does not hold certificates unique by content, so several names
* can carry the same PEM.
*/
export async function ensureImportedCertificatePresent(client: E2eApiClient, name: string): Promise<void> {
if (await findCertificate(client, name)) {
return;
}

await runJob(
client,
() => client.api.callAndGetJobId('certificate.create', [{
name,
create_type: 'CERTIFICATE_CREATE_IMPORTED',
certificate: testCertificatePem,
privatekey: testPrivateKeyPem,
}]),
{
timeoutMs: slowCallTimeoutMs,
whatItCosts: `the certificate ${name} could not be imported, so there is nothing to act on`,
confirm: async () => (await findCertificate(client, name)) !== undefined,
},
);
}

export interface CsrSeed {
name: string;
common: string;
Expand Down
66 changes: 66 additions & 0 deletions e2e/fixtures/test-certificate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* A fixed, test-only self-signed certificate and its private key, for the
* Import Certificate journey.
*
* Checked in rather than generated per run because Node's crypto can make a key
* pair but cannot sign an X.509 certificate without a library. Neither is a
* credential for anything: the key signs only this certificate, and the
* certificate names a host under `example.com`.
*
* RSA 2048, SHA256, valid from 2026-01-01 to 2076-01-01. The key is unencrypted
* PKCS#8, the form middleware re-exports an imported key in, so the key the
* appliance hands back is the one imported here apart from a trailing newline.
*/

/** The certificate's subject common name, and its one SAN. */
export const testCertificateCommon = 'e2e-import.example.com';

export const testCertificatePem = '-----BEGIN CERTIFICATE-----\n'
+ 'MIIDTzCCAjegAwIBAgIUZuIZf7Ev+nTzmLmBHQuy9rJXf+QwDQYJKoZIhvcNAQEL\n'
+ 'BQAwRjELMAkGA1UEBhMCVVMxFjAUBgNVBAoMDWlYc3lzdGVtcyBFMkUxHzAdBgNV\n'
+ 'BAMMFmUyZS1pbXBvcnQuZXhhbXBsZS5jb20wIBcNMjYwMTAxMDAwMDAwWhgPMjA3\n'
+ 'NjAxMDEwMDAwMDBaMEYxCzAJBgNVBAYTAlVTMRYwFAYDVQQKDA1pWHN5c3RlbXMg\n'
+ 'RTJFMR8wHQYDVQQDDBZlMmUtaW1wb3J0LmV4YW1wbGUuY29tMIIBIjANBgkqhkiG\n'
+ '9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqzByiDF1ysiiO/9irJmnmhytMgiliwGAg5SC\n'
+ 'XwXfZPHTp3oZREP2ZIpiPbbGmRLHuQngnzuAP0g1WxY6g93Q82MUDeGO+ekRizF8\n'
+ 'cGWX375h8EOdp1xKxJ89tuHuLA2/4eHFuCaIIZULtd12Jk4xjfS1vRvPi6GKZb4S\n'
+ '4sDukAJtoyaynAbp8n8OKCRL40/u2HCKs61Pv6KueXfoEpvD35FddKyVO3ex6f5k\n'
+ 'nZIRtM/v1PIT+7XYZSeRpWg2EzPYbLl3DBce9v1JevXivAjlZKnkU3vmr6IS1ncI\n'
+ 'zyI1Eg5NrP/COx5pnFd8BD25tcfqxSmK7xZYmQBPAjz65JSK2QIDAQABozMwMTAh\n'
+ 'BgNVHREEGjAYghZlMmUtaW1wb3J0LmV4YW1wbGUuY29tMAwGA1UdEwEB/wQCMAAw\n'
+ 'DQYJKoZIhvcNAQELBQADggEBAG14ySrEJp3O8fZPRNggQCIhy8rCaMeYAoplnI9s\n'
+ 'LiXPk57N9l8dQPxSt8mBWM6G/GSZvkXP21VulBh/vHPs8o4WCKT2bW8JPPuftxZc\n'
+ 'azdrCkqfZoU1e+GU3UO3vZe8gsCqm2+eujOWOzuRjCT3zHRE7vD5rcj4DW3RgJYB\n'
+ 'Dgu4X7JTr8Rg8o0S/xraiqmNnZYePgami/WvSHZtfRwb8NQC4Kzo/JAI2HEMwFEZ\n'
+ 'AntWPHK/YDbA7J2PE1ZVDEgiHR77xP8x9lT/jrdLtz2L+wzpZV2E6QurmHYwEhdz\n'
+ 'kk4HA8DID1OCCRvO3xEYMdEdAmLMZSci9Q1J8uKvgmAhjXA=\n'
+ '-----END CERTIFICATE-----';

export const testPrivateKeyPem = '-----BEGIN PRIVATE KEY-----\n'
+ 'MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCrMHKIMXXKyKI7\n'
+ '/2KsmaeaHK0yCKWLAYCDlIJfBd9k8dOnehlEQ/ZkimI9tsaZEse5CeCfO4A/SDVb\n'
+ 'FjqD3dDzYxQN4Y756RGLMXxwZZffvmHwQ52nXErEnz224e4sDb/h4cW4JoghlQu1\n'
+ '3XYmTjGN9LW9G8+LoYplvhLiwO6QAm2jJrKcBunyfw4oJEvjT+7YcIqzrU+/oq55\n'
+ 'd+gSm8PfkV10rJU7d7Hp/mSdkhG0z+/U8hP7tdhlJ5GlaDYTM9hsuXcMFx72/Ul6\n'
+ '9eK8COVkqeRTe+avohLWdwjPIjUSDk2s/8I7HmmcV3wEPbm1x+rFKYrvFliZAE8C\n'
+ 'PPrklIrZAgMBAAECggEAAVkF9rPdR5STPMaAWzn13ne9oCVm1ixAvkh0LsBMFcOz\n'
+ 'QyTW+wwzHVNy6sa5tz+dh0rbipE9inYmqmxvv3/gyfYygaqB8VMnfonAMz0l9vzg\n'
+ 'BVU6Dnv1fdh//Y0DXOgqzhYfSWCRnboRcyBkX3oXFWplYo+5AlJgoWlkdL7blhvC\n'
+ 'ltnANQ7GfoeZqQyHAZE9i3rI+GGJrgUUMW2m6OVOuEj7WpR8BrS1Akx+YpR51C7n\n'
+ 'LoQUp7sKH5Y1bYoRaEbFRt6+tNK/5xD04mGWd4SvCaMlLwkcJvRF+cvzSS8b9mRD\n'
+ 'kDrZ1bbasQ0siQY6ZlGRcdlDg0HEp87H7xvIUQidZQKBgQDjXOtoReNJh2tOVrag\n'
+ 'lHcAsVOyw5mHch4gxgmcJWFydS7Go70SSeRP3Yzp5MG8i7Rfovx7GQYnAq61XKem\n'
+ 'Zs6Dc5QKBTuj1SPNVtD+frgqUG6bU4L4S/dwDV3y2nwrUMiWCefi0pkFoGpcd3Bk\n'
+ 'AiVAJrBIs/eVdBc+EgjBjZ/y6wKBgQDAwEPhUbNi1cJ8qVSAyx03TPgy0WmWgIV+\n'
+ 'zag8EmMaGlt9xCJQrpRZ9csOIvzIwEsygdI2IhIPYYkZDQpeU9zMGeZ6DJ8taxy2\n'
+ 'JyTkuEUUfFpNHktnFvR4Q8vdSkBc6pk5HDntt/gPMAQm0g0S6IUbL/4749vcZsZj\n'
+ 'vdQIpFEgSwKBgBdfSO/KJ2g6nIRME7AEMwuGLxV0WAb9ay1xTjGYAiFy/GD9GgWU\n'
+ 'RP0UyFSO6H+KWPtv1MaOtvwxoWYRZUaM0PPgYLp6+TuJQInuP8O5oRLwe75J4m/F\n'
+ '12TUavD5FsSZJFWQJR9DCOn0KJriyFHviobCs2J15aCec3k0/2NuPKALAoGAYTrb\n'
+ 'Qva2mZ2gnMfB87IBy9qMvP272Ip+TKerPJo92XYAPuHIOZDHDtYZFsl5YD7378Ni\n'
+ '6aGoIJAf2z4jw89j6qUKCPkC9qM4eX4ncySs3BeaaZpliNwgd+YjGT6SVwyUPCXL\n'
+ '5ulNPUQIZBjDNY2LT1qXUViwmG5Tlw6zvaQ9tB8CgYANfRvZD1ywgfDP5JjFY7Wx\n'
+ 'dk6Yw4mBNG+8RoNnv9wTBDg/4fS4D7fyI8haF56mdYFgCoNlNA4VhHfmtOdqTEBI\n'
+ 'Xd9IzfGmvpiU9IswVngH9N+xZBia7pbKqxPSRHU6SKFc1YqJwfZo+okWLsxg7aZ8\n'
+ 'sR9GHLNxDlRn/p5mzj5G3g==\n'
+ '-----END PRIVATE KEY-----';
103 changes: 100 additions & 3 deletions e2e/flows/certificates.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
/**
* Certificate signing requests, driven through the UI from Credentials > Certificates.
* Certificates and certificate signing requests, driven through the UI from
* Credentials > Certificates.
*/
import { expect, type Page } from '@playwright/test';
import { goToCertificates } from './navigation';
import { csrLocators } from '../locators/certificates';
import { certificateLocators, csrLocators } from '../locators/certificates';
import { confirmDialogLocators } from '../locators/dialogs';

/** Creating a CSR generates a key pair and deleting one is a job; neither is instant. */
/**
* Creating a CSR generates a key pair, and importing, updating and deleting a
* certificate are all jobs; none of them is instant.
*/
const saveTimeoutMs = 90_000;

export interface CsrSubject {
Expand Down Expand Up @@ -90,3 +94,96 @@ export async function deleteCsr(page: Page, name: string): Promise<void> {

await expect(page.locator(list.row(name))).toBeHidden({ timeout: saveTimeoutMs });
}

export interface ImportedCertificate {
name: string;
/** PEM, pasted as it stands. */
certificate: string;
privateKey: string;
}

/**
* Imports a certificate from the Certificates card, starting from the sidebar,
* and waits for the panel to close.
*
* "Add To Trusted Store" and the passphrase are left at their defaults: off, and
* none. The panel closing is the app's own signal that the create job succeeded
* — a failure raises an error dialog and leaves the form open.
*/
export async function importCertificate(page: Page, certificate: ImportedCertificate): Promise<void> {
const { card, importForm } = certificateLocators;
await goToCertificates(page);
await page.locator(card.import).click();

await page.locator(importForm.name).fill(certificate.name);
await page.locator(importForm.certificate).fill(certificate.certificate);
await page.locator(importForm.privateKey).fill(certificate.privateKey);

await page.locator(importForm.save).click();
await expect(page.locator(importForm.save)).toBeHidden({ timeout: saveTimeoutMs });
}

/**
* Opens the Edit Certificate panel from a row's menu, starting from the sidebar.
*
* Waits on the Identifier holding this certificate's name, which is the panel's
* own sign that it has the record to edit.
*/
export async function openEditCertificateForm(page: Page, name: string): Promise<void> {
const { card, editForm } = certificateLocators;
await goToCertificates(page);
await page.locator(card.rowMenu(name)).click();
await page.locator(card.rowMenuEdit(name)).click();

await expect(page.locator(editForm.name)).toHaveValue(name);
}

/** Saves the open Edit Certificate panel and waits for it to close — see {@link importCertificate}. */
export async function saveCertificateForm(page: Page): Promise<void> {
const { save } = certificateLocators.editForm;
await page.locator(save).click();
await expect(page.locator(save)).toBeHidden({ timeout: saveTimeoutMs });
}

/**
* Opens the certificate's or the key's PEM from the open Edit Certificate panel's
* Actions menu, and returns what the dialog shows, closing the dialog again.
*
* The dialog is read before it is closed: the panel stays open beneath it, so the
* other one can be opened next.
*/
export async function viewCertificatePem(page: Page, which: 'certificate' | 'key'): Promise<string> {
const { editForm, viewDialog } = certificateLocators;
await page.locator(editForm.actions).click();
await page.locator(which === 'certificate' ? editForm.viewCertificate : editForm.viewKey).click();

await expect(page.locator(viewDialog.title)).toBeVisible();
const pem = await page.locator(viewDialog.pem).inputValue();

await page.locator(viewDialog.close).click();
await expect(page.locator(viewDialog.title)).toBeHidden();

return pem;
}

/**
* Deletes a certificate from its row's menu, through webui's shared confirm,
* starting from the sidebar, and waits for its row to go.
*
* The card raises the confirm with `hideCheckbox: true`, the same as the CSR card
* — see {@link deleteCsr}. Force is left off. The delete is a job, and the card
* reloads only once it has finished, so the row going is the wait.
*/
export async function deleteCertificate(page: Page, name: string): Promise<void> {
const { card } = certificateLocators;
await goToCertificates(page);
await page.locator(card.rowMenu(name)).click();
await page.locator(card.rowMenuDelete(name)).click();
await expect(page.locator(confirmDialogLocators.title)).toBeVisible();

const confirm = page.locator(confirmDialogLocators.confirm);
await expect(confirm).toBeEnabled();
await confirm.click();

await expect(page.locator(card.row(name))).toBeHidden({ timeout: saveTimeoutMs });
}
89 changes: 87 additions & 2 deletions e2e/locators/certificates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/**
* The Certificate Signing Requests card on Credentials > Certificates, and its
* Add CSR wizard.
* The Certificates and Certificate Signing Requests cards on Credentials >
* Certificates, the Import Certificate form, the Edit Certificate form, and the
* dialog that shows a certificate's or key's PEM.
*
* What follows, down to {@link csrLocators}, describes the CSR card; the
* Certificates card is described at {@link certificateLocators}.
*
* Every id on a row is built from the card's own `uniqueRowTag`, `csr-<name>`, a
* plain concatenation with no pre-normalizing, so {@link kebabTestSegment} is the
Expand Down Expand Up @@ -78,3 +82,84 @@
save: '[data-test="button-save"]',
},
} as const;

function certificateRowTag(name: string): string {
return kebabTestSegment(`cert-${name}`);
}

/**
* The Certificates card lists four certificates before its Show More pager
* (`[pageSize]="4"`), oldest first, the same as the CSR card. A fresh appliance
* holds one, `truenas_default`.
*/
export const certificateCardPageSize = 4;

/**
* The Certificates card, built the same way as the CSR card: `uniqueRowTag` is
* `cert-<name>`, a plain concatenation, and each cell is tagged
* `[('<Title>' | translate), uniqueRowTag(row), 'row-text']`. Its three row
* actions (Download, Edit, Delete) sit in a kebab menu.
*/
export const certificateLocators = {
card: {
/** `<tn-button [testId]="['import-certificate']">` in the card footer. */
import: '[data-test="button-import-certificate"]',

row: (name: string): string => `[data-test="row-${certificateRowTag(name)}"]`,

name: (name: string): string => `[data-test="text-name-${certificateRowTag(name)}-row-text"]`,

common: (name: string): string => `[data-test="text-cn-${certificateRowTag(name)}-row-text"]`,

rowMenu: (name: string): string => `[data-test="button-${certificateRowTag(name)}-more-action"]`,

rowMenuEdit: (name: string): string => (
`[data-test="button-${certificateRowTag(name)}-more-action-mdi-pencil-row-action"]`
),

rowMenuDelete: (name: string): string => (
`[data-test="button-${certificateRowTag(name)}-more-action-mdi-delete-row-action"]`
),
},

/** The Import Certificate side panel. */
importForm: {
name: '[data-test="input-name"]',
addToTrustedStore: '[data-test="checkbox-add-to-trusted-store"]',

Check notice on line 128 in e2e/locators/certificates.ts

View workflow job for this annotation

GitHub Actions / review / Automatic PR review

LOW: certificateLocators.importForm.addToTrustedStore is never used; assert its default in the import test or drop it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — importForm.addToTrustedStore isn't used anywhere: the import test leaves the flag at its default, and only editForm.addToTrustedStore gets .check()ed. The PR description says this is on purpose. Two cheap ways to make it earn its place: have the import test assert the box starts unchecked (await expect(page.locator(importForm.addToTrustedStore)).not.toBeChecked()), or drop it until a test needs it. 🙂

/**
* Both PEM fields are multiline `tn-input`s, which render a `<textarea>` typed
* `textarea` — see `locators/api-keys.ts`.
*/
certificate: '[data-test="textarea-certificate"]',
privateKey: '[data-test="textarea-privatekey"]',
/** `<tn-button [testId]="'save'">` on the side panel container, labelled Import here. */
save: '[data-test="button-save"]',
},

/** The Edit Certificate side panel, opened from a row's Edit. */
editForm: {
/** Labelled Identifier. */
name: '[data-test="input-name"]',
addToTrustedStore: '[data-test="checkbox-add-to-trusted-store"]',
/**
* The panel footer's Actions menu, `footerMenu` in `certificate-edit.component.ts`,
* rendered by the side panel host as a `tn-icon-button` beside Save.
*/
actions: '[data-test="button-certificate-actions"]',
/** Its `tn-menu-item`s: View/Download Certificate, and View/Download Key. */
viewCertificate: '[data-test="button-view-certificate-or-csr"]',
viewKey: '[data-test="button-view-key"]',
save: '[data-test="button-save"]',
},

/**
* `<tn-dialog-shell testId="view-certificate">`, raised by
* either menu item: one dialog for the certificate and the key alike, told apart
* only by what its read-only textarea holds.
*/
viewDialog: {
title: '[data-test="dialog-title-view-certificate"]',
pem: '[data-test="textarea-certificate"]',
close: '[data-test="button-close"]',
},
} as const;
Loading
Loading