fix(web): fence repository access route state
This commit is contained in:
@@ -47,7 +47,8 @@
|
|||||||
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
|
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
|
||||||
);
|
);
|
||||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||||
const accessProjection = untrack(() => data.accessProjection);
|
let accessProjection = $state(untrack(() => data.accessProjection));
|
||||||
|
let pageEpoch = 0;
|
||||||
let pendingOperations = $state<string[]>([]);
|
let pendingOperations = $state<string[]>([]);
|
||||||
let rotateCredentialId = $state<string | null>(null);
|
let rotateCredentialId = $state<string | null>(null);
|
||||||
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
|
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
|
||||||
@@ -104,6 +105,52 @@
|
|||||||
hostExpectedRevision === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null
|
hostExpectedRevision === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function resetPageState(next: PageProps['data']): void {
|
||||||
|
pageEpoch += 1;
|
||||||
|
credentials = sortCredentials(next.credentials);
|
||||||
|
publicKeys = Object.fromEntries(next.publicKeys.map((key) => [key.credential_id, key]));
|
||||||
|
hostTrusts = next.hostTrusts;
|
||||||
|
accessProjection = next.accessProjection;
|
||||||
|
pendingOperations = [];
|
||||||
|
rotateCredentialId = null;
|
||||||
|
copiedCredentialId = null;
|
||||||
|
credentialNotice = null;
|
||||||
|
credentialFormNotice = null;
|
||||||
|
credentialRowNotices = {};
|
||||||
|
hostNotice = null;
|
||||||
|
hostFormNotice = null;
|
||||||
|
hostRowNotices = {};
|
||||||
|
publicKeyNotices = {};
|
||||||
|
credentialForm = null;
|
||||||
|
generateCredentialId = '';
|
||||||
|
generateCredentialName = '';
|
||||||
|
generateErrors = {};
|
||||||
|
credentialId = '';
|
||||||
|
credentialName = '';
|
||||||
|
privateKey = '';
|
||||||
|
passphrase = '';
|
||||||
|
importErrors = {};
|
||||||
|
rotatePrivateKey = '';
|
||||||
|
rotatePassphrase = '';
|
||||||
|
rotateErrors = {};
|
||||||
|
credentialReturnFocus = null;
|
||||||
|
hostEditorOpen = false;
|
||||||
|
hostTrustId = '';
|
||||||
|
hostname = '';
|
||||||
|
port = 22;
|
||||||
|
hostKey = '';
|
||||||
|
hostExpectedRevision = null;
|
||||||
|
hostErrors = {};
|
||||||
|
hostReturnFocus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const next = data;
|
||||||
|
untrack(() => resetPageState(next));
|
||||||
|
});
|
||||||
|
|
||||||
|
class StaleRepositoryAccessRequestError extends Error {}
|
||||||
|
|
||||||
function operationId(prefix: string): string {
|
function operationId(prefix: string): string {
|
||||||
return `${prefix}-${crypto.randomUUID()}`;
|
return `${prefix}-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
@@ -112,24 +159,51 @@
|
|||||||
return { tone: 'error', text: error instanceof Error ? error.message : fallback };
|
return { tone: 'error', text: error instanceof Error ? error.message : fallback };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pendingOperationKey(operation: string): string {
|
||||||
|
return `${pageEpoch}:${operation}`;
|
||||||
|
}
|
||||||
|
|
||||||
function isPending(operation: string): boolean {
|
function isPending(operation: string): boolean {
|
||||||
return pendingOperations.includes(operation);
|
return pendingOperations.includes(pendingOperationKey(operation));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startOperation(operation: string): boolean {
|
function startOperation(operation: string): string | null {
|
||||||
if (isPending(operation)) return false;
|
const key = pendingOperationKey(operation);
|
||||||
pendingOperations = [...pendingOperations, operation];
|
if (pendingOperations.includes(key)) return null;
|
||||||
return true;
|
pendingOperations = [...pendingOperations, key];
|
||||||
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishOperation(operation: string): void {
|
function finishOperation(operationKey: string): void {
|
||||||
pendingOperations = pendingOperations.filter((entry) => entry !== operation);
|
pendingOperations = pendingOperations.filter((entry) => entry !== operationKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentOperation(operationKey: string): boolean {
|
||||||
|
return operationKey.startsWith(`${pageEpoch}:`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCurrentOperation(operationKey: string): void {
|
||||||
|
if (!isCurrentOperation(operationKey)) throw new StaleRepositoryAccessRequestError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialEditorPending(): boolean {
|
||||||
|
const credentialRotationPrefix = `${pageEpoch}:rotate-`;
|
||||||
|
const hostRotationPrefix = `${pageEpoch}:rotate-host-`;
|
||||||
|
return isPending('generate-credential') || isPending('import-credential') || pendingOperations.some((entry) => entry.startsWith(credentialRotationPrefix) && !entry.startsWith(hostRotationPrefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostEditorPending(): boolean {
|
||||||
|
return isPending('add-host-trust') || pendingOperations.some((entry) => entry.startsWith(`${pageEpoch}:rotate-host-`));
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentFocus(): HTMLElement | null {
|
function currentFocus(): HTMLElement | null {
|
||||||
return document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
return document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusIsInside(element: HTMLElement | undefined): boolean {
|
||||||
|
return element?.contains(document.activeElement) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreFocus(target: HTMLElement | null, fallbackId?: string): Promise<void> {
|
async function restoreFocus(target: HTMLElement | null, fallbackId?: string): Promise<void> {
|
||||||
await tick();
|
await tick();
|
||||||
const fallback = fallbackId ? document.getElementById(fallbackId) : null;
|
const fallback = fallbackId ? document.getElementById(fallbackId) : null;
|
||||||
@@ -157,11 +231,16 @@
|
|||||||
parse: ((value: unknown) => T) | null,
|
parse: ((value: unknown) => T) | null,
|
||||||
operation: string
|
operation: string
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
|
const epoch = pageEpoch;
|
||||||
const response = await fetch(`${base}${path}`, {
|
const response = await fetch(`${base}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
if (epoch !== pageEpoch) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
throw new StaleRepositoryAccessRequestError();
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await response.body?.cancel();
|
await response.body?.cancel();
|
||||||
throw repositoryAccessRequestError(response.status, operation);
|
throw repositoryAccessRequestError(response.status, operation);
|
||||||
@@ -176,27 +255,37 @@
|
|||||||
}
|
}
|
||||||
if (response.status === 204) throw new Error('Repository Access returned an unexpected empty response.');
|
if (response.status === 204) throw new Error('Repository Access returned an unexpected empty response.');
|
||||||
const payload = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
|
const payload = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
|
||||||
|
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
|
||||||
return parse(payload);
|
return parse(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> {
|
async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> {
|
||||||
|
const epoch = pageEpoch;
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${base}/credentials/${encodeURIComponent(credentialId)}/public-key`,
|
`${base}/credentials/${encodeURIComponent(credentialId)}/public-key`,
|
||||||
{ headers: { accept: 'application/json' } }
|
{ headers: { accept: 'application/json' } }
|
||||||
);
|
);
|
||||||
|
if (epoch !== pageEpoch) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
throw new StaleRepositoryAccessRequestError();
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await response.body?.cancel();
|
await response.body?.cancel();
|
||||||
throw repositoryAccessRequestError(response.status, 'load this public key');
|
throw repositoryAccessRequestError(response.status, 'load this public key');
|
||||||
}
|
}
|
||||||
const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
|
const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
|
||||||
|
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
|
||||||
return parseRepositorySshPublicKey(body);
|
return parseRepositorySshPublicKey(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPublicKey(credentialId: string): Promise<void> {
|
async function refreshPublicKey(credentialId: string): Promise<void> {
|
||||||
|
const epoch = pageEpoch;
|
||||||
try {
|
try {
|
||||||
const publicKey = await loadPublicKey(credentialId);
|
const publicKey = await loadPublicKey(credentialId);
|
||||||
|
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
|
||||||
publicKeys = { ...publicKeys, [credentialId]: publicKey };
|
publicKeys = { ...publicKeys, [credentialId]: publicKey };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof StaleRepositoryAccessRequestError) throw error;
|
||||||
publicKeyNotices = {
|
publicKeyNotices = {
|
||||||
...publicKeyNotices,
|
...publicKeyNotices,
|
||||||
[credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.')
|
[credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.')
|
||||||
@@ -218,6 +307,7 @@
|
|||||||
|
|
||||||
async function closeCredentialForms(restore = true) {
|
async function closeCredentialForms(restore = true) {
|
||||||
const returnFocus = credentialReturnFocus;
|
const returnFocus = credentialReturnFocus;
|
||||||
|
const shouldRestoreFocus = restore && focusIsInside(credentialFormElement);
|
||||||
credentialForm = null;
|
credentialForm = null;
|
||||||
generateCredentialId = '';
|
generateCredentialId = '';
|
||||||
generateCredentialName = '';
|
generateCredentialName = '';
|
||||||
@@ -229,7 +319,7 @@
|
|||||||
importErrors = {};
|
importErrors = {};
|
||||||
credentialFormNotice = null;
|
credentialFormNotice = null;
|
||||||
credentialReturnFocus = null;
|
credentialReturnFocus = null;
|
||||||
if (restore) await restoreFocus(returnFocus);
|
if (shouldRestoreFocus) await restoreFocus(returnFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateCredential() {
|
async function generateCredential() {
|
||||||
@@ -238,8 +328,8 @@
|
|||||||
await focusFirstInvalid(credentialFormElement);
|
await focusFirstInvalid(credentialFormElement);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const operation = 'generate-credential';
|
const operation = startOperation('generate-credential');
|
||||||
if (!startOperation(operation)) return;
|
if (!operation) return;
|
||||||
credentialFormNotice = null;
|
credentialFormNotice = null;
|
||||||
try {
|
try {
|
||||||
const body: GenerateRepositorySshCredentialRequest = {
|
const body: GenerateRepositorySshCredentialRequest = {
|
||||||
@@ -248,11 +338,15 @@
|
|||||||
name: generateCredentialName.trim()
|
name: generateCredentialName.trim()
|
||||||
};
|
};
|
||||||
const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential');
|
const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential');
|
||||||
|
requireCurrentOperation(operation);
|
||||||
credentials = sortCredentials([...credentials.filter((item) => item.credential_id !== created.credential_id), created]);
|
credentials = sortCredentials([...credentials.filter((item) => item.credential_id !== created.credential_id), created]);
|
||||||
await refreshPublicKey(created.credential_id);
|
await refreshPublicKey(created.credential_id);
|
||||||
|
requireCurrentOperation(operation);
|
||||||
await closeCredentialForms();
|
await closeCredentialForms();
|
||||||
|
requireCurrentOperation(operation);
|
||||||
credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` };
|
credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof StaleRepositoryAccessRequestError) return;
|
||||||
credentialFormNotice = operationError(error, 'Failed to generate the SSH credential.');
|
credentialFormNotice = operationError(error, 'Failed to generate the SSH credential.');
|
||||||
} finally {
|
} finally {
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
@@ -260,19 +354,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyPublicKey(credential: RepositorySshCredential) {
|
async function copyPublicKey(credential: RepositorySshCredential) {
|
||||||
const operation = `copy-${credential.credential_id}`;
|
const operation = startOperation(`copy-${credential.credential_id}`);
|
||||||
if (!startOperation(operation)) return;
|
if (!operation) return;
|
||||||
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } };
|
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } };
|
||||||
try {
|
try {
|
||||||
const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id);
|
const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id);
|
||||||
|
requireCurrentOperation(operation);
|
||||||
publicKeys = { ...publicKeys, [credential.credential_id]: publicKey };
|
publicKeys = { ...publicKeys, [credential.credential_id]: publicKey };
|
||||||
await navigator.clipboard.writeText(publicKey.public_key);
|
await navigator.clipboard.writeText(publicKey.public_key);
|
||||||
|
requireCurrentOperation(operation);
|
||||||
copiedCredentialId = credential.credential_id;
|
copiedCredentialId = credential.credential_id;
|
||||||
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } };
|
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } };
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (copiedCredentialId === credential.credential_id) copiedCredentialId = null;
|
if (isCurrentOperation(operation) && copiedCredentialId === credential.credential_id) copiedCredentialId = null;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof StaleRepositoryAccessRequestError) return;
|
||||||
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') };
|
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') };
|
||||||
} finally {
|
} finally {
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
@@ -285,8 +382,8 @@
|
|||||||
await focusFirstInvalid(credentialFormElement);
|
await focusFirstInvalid(credentialFormElement);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const operation = 'import-credential';
|
const operation = startOperation('import-credential');
|
||||||
if (!startOperation(operation)) return;
|
if (!operation) return;
|
||||||
credentialFormNotice = null;
|
credentialFormNotice = null;
|
||||||
try {
|
try {
|
||||||
const body: CreateRepositorySshCredentialRequest = {
|
const body: CreateRepositorySshCredentialRequest = {
|
||||||
@@ -297,15 +394,21 @@
|
|||||||
passphrase: passphrase || null
|
passphrase: passphrase || null
|
||||||
};
|
};
|
||||||
const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential');
|
const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential');
|
||||||
|
requireCurrentOperation(operation);
|
||||||
credentials = sortCredentials([...credentials, created]);
|
credentials = sortCredentials([...credentials, created]);
|
||||||
await refreshPublicKey(created.credential_id);
|
await refreshPublicKey(created.credential_id);
|
||||||
|
requireCurrentOperation(operation);
|
||||||
await closeCredentialForms();
|
await closeCredentialForms();
|
||||||
|
requireCurrentOperation(operation);
|
||||||
credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` };
|
credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof StaleRepositoryAccessRequestError) return;
|
||||||
credentialFormNotice = operationError(error, 'Credential import failed.');
|
credentialFormNotice = operationError(error, 'Credential import failed.');
|
||||||
} finally {
|
} finally {
|
||||||
privateKey = '';
|
if (isCurrentOperation(operation)) {
|
||||||
passphrase = '';
|
privateKey = '';
|
||||||
|
passphrase = '';
|
||||||
|
}
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +427,7 @@
|
|||||||
|
|
||||||
async function closeCredentialRotation(restore = true) {
|
async function closeCredentialRotation(restore = true) {
|
||||||
const returnFocus = credentialReturnFocus;
|
const returnFocus = credentialReturnFocus;
|
||||||
|
const shouldRestoreFocus = restore && focusIsInside(rotationFormElement);
|
||||||
const fallbackId = rotateCredentialId ? `rotate-credential-${rotateCredentialId}` : undefined;
|
const fallbackId = rotateCredentialId ? `rotate-credential-${rotateCredentialId}` : undefined;
|
||||||
rotateCredentialId = null;
|
rotateCredentialId = null;
|
||||||
rotatePrivateKey = '';
|
rotatePrivateKey = '';
|
||||||
@@ -331,7 +435,7 @@
|
|||||||
rotateErrors = {};
|
rotateErrors = {};
|
||||||
credentialFormNotice = null;
|
credentialFormNotice = null;
|
||||||
credentialReturnFocus = null;
|
credentialReturnFocus = null;
|
||||||
if (restore) await restoreFocus(returnFocus, fallbackId);
|
if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateCredential(credential: RepositorySshCredential) {
|
async function rotateCredential(credential: RepositorySshCredential) {
|
||||||
@@ -340,8 +444,8 @@
|
|||||||
await focusFirstInvalid(rotationFormElement);
|
await focusFirstInvalid(rotationFormElement);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const operation = `rotate-${credential.credential_id}`;
|
const operation = startOperation(`rotate-${credential.credential_id}`);
|
||||||
if (!startOperation(operation)) return;
|
if (!operation) return;
|
||||||
credentialFormNotice = null;
|
credentialFormNotice = null;
|
||||||
try {
|
try {
|
||||||
const body: RotateRepositorySshCredentialRequest = {
|
const body: RotateRepositorySshCredentialRequest = {
|
||||||
@@ -357,29 +461,36 @@
|
|||||||
parseRepositorySshCredential,
|
parseRepositorySshCredential,
|
||||||
`rotate ${credential.name}`
|
`rotate ${credential.name}`
|
||||||
);
|
);
|
||||||
|
requireCurrentOperation(operation);
|
||||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||||
await refreshPublicKey(rotated.credential_id);
|
await refreshPublicKey(rotated.credential_id);
|
||||||
|
requireCurrentOperation(operation);
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
await closeCredentialRotation();
|
await closeCredentialRotation();
|
||||||
|
requireCurrentOperation(operation);
|
||||||
credentialRowNotices = {
|
credentialRowNotices = {
|
||||||
...credentialRowNotices,
|
...credentialRowNotices,
|
||||||
[rotated.credential_id]: { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` }
|
[rotated.credential_id]: { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` }
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof StaleRepositoryAccessRequestError) return;
|
||||||
credentialFormNotice = operationError(error, 'Credential rotation failed.');
|
credentialFormNotice = operationError(error, 'Credential rotation failed.');
|
||||||
} finally {
|
} finally {
|
||||||
rotatePrivateKey = '';
|
if (isCurrentOperation(operation)) {
|
||||||
rotatePassphrase = '';
|
rotatePrivateKey = '';
|
||||||
|
rotatePassphrase = '';
|
||||||
|
}
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCredential(credential: RepositorySshCredential) {
|
async function deleteCredential(credential: RepositorySshCredential) {
|
||||||
if (credential.referenced_repositories.length > 0) return;
|
if (credential.referenced_repositories.length > 0) return;
|
||||||
const operation = `delete-${credential.credential_id}`;
|
const operationName = `delete-${credential.credential_id}`;
|
||||||
if (isPending(operation)) return;
|
if (isPending(operationName)) return;
|
||||||
if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return;
|
if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return;
|
||||||
if (!startOperation(operation)) return;
|
const operation = startOperation(operationName);
|
||||||
|
if (!operation) return;
|
||||||
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: { tone: 'success', text: 'Deleting credential…' } };
|
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: { tone: 'success', text: 'Deleting credential…' } };
|
||||||
try {
|
try {
|
||||||
const body: DeleteRepositorySshCredentialRequest = {
|
const body: DeleteRepositorySshCredentialRequest = {
|
||||||
@@ -387,12 +498,14 @@
|
|||||||
expected_revision: credential.current_revision
|
expected_revision: credential.current_revision
|
||||||
};
|
};
|
||||||
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`);
|
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`);
|
||||||
|
requireCurrentOperation(operation);
|
||||||
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
||||||
const remainingPublicKeys = { ...publicKeys };
|
const remainingPublicKeys = { ...publicKeys };
|
||||||
delete remainingPublicKeys[credential.credential_id];
|
delete remainingPublicKeys[credential.credential_id];
|
||||||
publicKeys = remainingPublicKeys;
|
publicKeys = remainingPublicKeys;
|
||||||
credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` };
|
credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof StaleRepositoryAccessRequestError) return;
|
||||||
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: operationError(error, 'Credential deletion failed.') };
|
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: operationError(error, 'Credential deletion failed.') };
|
||||||
} finally {
|
} finally {
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
@@ -429,6 +542,7 @@
|
|||||||
|
|
||||||
async function closeHostEditor(restore = true) {
|
async function closeHostEditor(restore = true) {
|
||||||
const returnFocus = hostReturnFocus;
|
const returnFocus = hostReturnFocus;
|
||||||
|
const shouldRestoreFocus = restore && focusIsInside(hostFormElement);
|
||||||
const fallbackId = hostExpectedRevision !== null ? `rotate-host-${hostTrustId}` : undefined;
|
const fallbackId = hostExpectedRevision !== null ? `rotate-host-${hostTrustId}` : undefined;
|
||||||
hostEditorOpen = false;
|
hostEditorOpen = false;
|
||||||
hostTrustId = '';
|
hostTrustId = '';
|
||||||
@@ -439,7 +553,7 @@
|
|||||||
hostErrors = {};
|
hostErrors = {};
|
||||||
hostFormNotice = null;
|
hostFormNotice = null;
|
||||||
hostReturnFocus = null;
|
hostReturnFocus = null;
|
||||||
if (restore) await restoreFocus(returnFocus, fallbackId);
|
if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveHostTrust() {
|
async function saveHostTrust() {
|
||||||
@@ -454,8 +568,8 @@
|
|||||||
hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' };
|
hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const operation = rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust';
|
const operation = startOperation(rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust');
|
||||||
if (!startOperation(operation)) return;
|
if (!operation) return;
|
||||||
hostFormNotice = null;
|
hostFormNotice = null;
|
||||||
try {
|
try {
|
||||||
const body: PutRepositorySshHostTrustRequest = {
|
const body: PutRepositorySshHostTrustRequest = {
|
||||||
@@ -467,15 +581,18 @@
|
|||||||
expected_revision: hostExpectedRevision
|
expected_revision: hostExpectedRevision
|
||||||
};
|
};
|
||||||
const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key');
|
const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key');
|
||||||
|
requireCurrentOperation(operation);
|
||||||
hostTrusts = rotating
|
hostTrusts = rotating
|
||||||
? hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry)
|
? hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry)
|
||||||
: [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id));
|
: [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id));
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
await closeHostEditor();
|
await closeHostEditor();
|
||||||
|
requireCurrentOperation(operation);
|
||||||
const notice: Notice = { tone: 'success', text: `${rotating ? 'Rotated' : 'Pinned'} the key for ${saved.hostname}:${saved.port}. This records host identity; it does not prove Git authentication succeeded.` };
|
const notice: Notice = { tone: 'success', text: `${rotating ? 'Rotated' : 'Pinned'} the key for ${saved.hostname}:${saved.port}. This records host identity; it does not prove Git authentication succeeded.` };
|
||||||
if (rotating) hostRowNotices = { ...hostRowNotices, [saved.host_trust_id]: notice };
|
if (rotating) hostRowNotices = { ...hostRowNotices, [saved.host_trust_id]: notice };
|
||||||
else hostNotice = notice;
|
else hostNotice = notice;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof StaleRepositoryAccessRequestError) return;
|
||||||
hostFormNotice = operationError(error, 'Failed to save the pinned host key.');
|
hostFormNotice = operationError(error, 'Failed to save the pinned host key.');
|
||||||
} finally {
|
} finally {
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
@@ -484,10 +601,11 @@
|
|||||||
|
|
||||||
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
|
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
|
||||||
if (hostTrust.referenced_repositories.length > 0) return;
|
if (hostTrust.referenced_repositories.length > 0) return;
|
||||||
const operation = `delete-host-${hostTrust.host_trust_id}`;
|
const operationName = `delete-host-${hostTrust.host_trust_id}`;
|
||||||
if (isPending(operation)) return;
|
if (isPending(operationName)) return;
|
||||||
if (!confirm(`Delete the pinned host key for ${hostTrust.hostname}:${hostTrust.port}? Future SSH connections cannot verify this host until another key is pinned.`)) return;
|
if (!confirm(`Delete the pinned host key for ${hostTrust.hostname}:${hostTrust.port}? Future SSH connections cannot verify this host until another key is pinned.`)) return;
|
||||||
if (!startOperation(operation)) return;
|
const operation = startOperation(operationName);
|
||||||
|
if (!operation) return;
|
||||||
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: { tone: 'success', text: 'Deleting pinned key…' } };
|
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: { tone: 'success', text: 'Deleting pinned key…' } };
|
||||||
try {
|
try {
|
||||||
const body: DeleteRepositorySshHostTrustRequest = {
|
const body: DeleteRepositorySshHostTrustRequest = {
|
||||||
@@ -495,9 +613,11 @@
|
|||||||
expected_revision: hostTrust.current_revision
|
expected_revision: hostTrust.current_revision
|
||||||
};
|
};
|
||||||
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`);
|
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`);
|
||||||
|
requireCurrentOperation(operation);
|
||||||
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
||||||
hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` };
|
hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof StaleRepositoryAccessRequestError) return;
|
||||||
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: operationError(error, 'Host key deletion failed.') };
|
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: operationError(error, 'Host key deletion failed.') };
|
||||||
} finally {
|
} finally {
|
||||||
finishOperation(operation);
|
finishOperation(operation);
|
||||||
@@ -563,8 +683,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if !data.credentialsError}
|
{#if !data.credentialsError}
|
||||||
<div class="repository-access-actions" aria-label="Add SSH credential">
|
<div class="repository-access-actions" aria-label="Add SSH credential">
|
||||||
<button type="button" class="secondary" onclick={() => void openCredentialForm('generate')}>Generate credential</button>
|
<button type="button" class="secondary" disabled={credentialEditorPending()} onclick={() => void openCredentialForm('generate')}>Generate credential</button>
|
||||||
<button type="button" class="secondary" onclick={() => void openCredentialForm('import')}>Import credential</button>
|
<button type="button" class="secondary" disabled={credentialEditorPending()} onclick={() => void openCredentialForm('import')}>Import credential</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -606,13 +726,13 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="repository-access-row-actions">
|
<div class="repository-access-row-actions">
|
||||||
<button type="button" class="secondary" disabled={isPending(`copy-${credential.credential_id}`)} onclick={() => void copyPublicKey(credential)}>
|
<button type="button" class="secondary" disabled={isPending(`copy-${credential.credential_id}`) || isPending(`rotate-${credential.credential_id}`) || isPending(`delete-${credential.credential_id}`)} onclick={() => void copyPublicKey(credential)}>
|
||||||
{isPending(`copy-${credential.credential_id}`) ? 'Loading…' : copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}
|
{isPending(`copy-${credential.credential_id}`) ? 'Loading…' : copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}
|
||||||
</button>
|
</button>
|
||||||
{#if credential.credential_id !== workspaceDefaultCredentialId}
|
{#if credential.credential_id !== workspaceDefaultCredentialId}
|
||||||
<button id={`rotate-credential-${credential.credential_id}`} type="button" class="secondary" disabled={isPending(`rotate-${credential.credential_id}`)} onclick={() => void openCredentialRotation(credential)}>Rotate</button>
|
<button id={`rotate-credential-${credential.credential_id}`} type="button" class="secondary" disabled={credentialEditorPending() || isPending(`delete-${credential.credential_id}`)} onclick={() => void openCredentialRotation(credential)}>Rotate</button>
|
||||||
{#if credential.referenced_repositories.length === 0}
|
{#if credential.referenced_repositories.length === 0}
|
||||||
<button type="button" class="danger" disabled={isPending(`delete-${credential.credential_id}`)} onclick={() => void deleteCredential(credential)}>Delete</button>
|
<button type="button" class="danger" disabled={isPending(`delete-${credential.credential_id}`) || isPending(`rotate-${credential.credential_id}`)} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -687,7 +807,7 @@
|
|||||||
<h2 id="host-trust-heading">Pinned SSH host keys</h2>
|
<h2 id="host-trust-heading">Pinned SSH host keys</h2>
|
||||||
<p>Keys used to verify the identity of a specific SSH host and port.</p>
|
<p>Keys used to verify the identity of a specific SSH host and port.</p>
|
||||||
</div>
|
</div>
|
||||||
{#if !data.hostTrustsError}<button type="button" class="secondary" disabled={isPending('add-host-trust')} onclick={() => void openNewHostTrust()}>Add pinned key</button>{/if}
|
{#if !data.hostTrustsError}<button type="button" class="secondary" disabled={hostEditorPending()} onclick={() => void openNewHostTrust()}>Add pinned key</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if hostNotice}<p class="repository-access-notice {hostNotice.tone}" role={hostNotice.tone === 'error' ? 'alert' : 'status'}>{hostNotice.text}</p>{/if}
|
{#if hostNotice}<p class="repository-access-notice {hostNotice.tone}" role={hostNotice.tone === 'error' ? 'alert' : 'status'}>{hostNotice.text}</p>{/if}
|
||||||
@@ -713,8 +833,8 @@
|
|||||||
<td>{hostTrust.referenced_repositories.length > 0 ? `Explicit: ${hostTrust.referenced_repositories.join(', ')}` : 'No explicit references; may apply automatically when this is the unique pin for a matching host and port.'}</td>
|
<td>{hostTrust.referenced_repositories.length > 0 ? `Explicit: ${hostTrust.referenced_repositories.join(', ')}` : 'No explicit references; may apply automatically when this is the unique pin for a matching host and port.'}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="repository-access-row-actions">
|
<div class="repository-access-row-actions">
|
||||||
<button id={`rotate-host-${hostTrust.host_trust_id}`} type="button" class="secondary" disabled={isPending(`rotate-host-${hostTrust.host_trust_id}`)} onclick={() => void editHostTrust(hostTrust)}>Rotate key</button>
|
<button id={`rotate-host-${hostTrust.host_trust_id}`} type="button" class="secondary" disabled={hostEditorPending() || isPending(`delete-host-${hostTrust.host_trust_id}`)} onclick={() => void editHostTrust(hostTrust)}>Rotate key</button>
|
||||||
{#if hostTrust.referenced_repositories.length === 0}<button type="button" class="danger" disabled={isPending(`delete-host-${hostTrust.host_trust_id}`)} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>{/if}
|
{#if hostTrust.referenced_repositories.length === 0}<button type="button" class="danger" disabled={isPending(`delete-host-${hostTrust.host_trust_id}`) || isPending(`rotate-host-${hostTrust.host_trust_id}`)} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if hostRowNotices[hostTrust.host_trust_id]}
|
{#if hostRowNotices[hostTrust.host_trust_id]}
|
||||||
<small class:field-error={hostRowNotices[hostTrust.host_trust_id].tone === 'error'} role={hostRowNotices[hostTrust.host_trust_id].tone === 'error' ? 'alert' : 'status'}>{hostRowNotices[hostTrust.host_trust_id].text}</small>
|
<small class:field-error={hostRowNotices[hostTrust.host_trust_id].tone === 'error'} role={hostRowNotices[hostTrust.host_trust_id].tone === 'error' ? 'alert' : 'status'}>{hostRowNotices[hostTrust.host_trust_id].text}</small>
|
||||||
|
|||||||
+144
-23
@@ -52,10 +52,13 @@ const hostTrust = {
|
|||||||
referenced_repositories: [],
|
referenced_repositories: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function pageData() {
|
function pageData(workspaceId = "workspace-1") {
|
||||||
return {
|
return {
|
||||||
workspaceId: "workspace-1",
|
workspaceId,
|
||||||
credentials: [defaultCredential, deployCredential],
|
credentials: [
|
||||||
|
{ ...defaultCredential, workspace_id: workspaceId },
|
||||||
|
{ ...deployCredential, workspace_id: workspaceId },
|
||||||
|
],
|
||||||
credentialsError: null,
|
credentialsError: null,
|
||||||
publicKeys: [{
|
publicKeys: [{
|
||||||
credential_id: "workspace-default",
|
credential_id: "workspace-default",
|
||||||
@@ -65,10 +68,10 @@ function pageData() {
|
|||||||
public_key: "ssh-ed25519 AAAA-default",
|
public_key: "ssh-ed25519 AAAA-default",
|
||||||
}],
|
}],
|
||||||
defaultPublicKeyError: null,
|
defaultPublicKeyError: null,
|
||||||
hostTrusts: [hostTrust],
|
hostTrusts: [{ ...hostTrust, workspace_id: workspaceId }],
|
||||||
hostTrustsError: null,
|
hostTrustsError: null,
|
||||||
accessProjection: {
|
accessProjection: {
|
||||||
workspace_id: "workspace-1",
|
workspace_id: workspaceId,
|
||||||
config_revision: 1,
|
config_revision: 1,
|
||||||
projection_digest: "sha256:projection",
|
projection_digest: "sha256:projection",
|
||||||
bindings: [],
|
bindings: [],
|
||||||
@@ -91,12 +94,15 @@ function deferred<T>() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage() {
|
function pageProps(data = pageData()): PageProps {
|
||||||
const props = {
|
return {
|
||||||
data: pageData(),
|
data,
|
||||||
params: { workspaceId: "workspace-1" },
|
params: { workspaceId: data.workspaceId },
|
||||||
} as unknown as PageProps;
|
} as unknown as PageProps;
|
||||||
return render(RepositoryAccessPage, props);
|
}
|
||||||
|
|
||||||
|
function renderPage(data = pageData()) {
|
||||||
|
return render(RepositoryAccessPage, pageProps(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -130,7 +136,7 @@ test("opens disclosures with focus, focuses invalid fields, and restores trigger
|
|||||||
expect(document.activeElement).toBe(trigger);
|
expect(document.activeElement).toBe(trigger);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fences a deferred submission without disabling unrelated controls and restores focus after success", async () => {
|
test("fences a deferred editor while an unrelated host draft remains active after success", async () => {
|
||||||
const createResponse = deferred<Response>();
|
const createResponse = deferred<Response>();
|
||||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
@@ -173,15 +179,24 @@ test("fences a deferred submission without disabling unrelated controls and rest
|
|||||||
(screen.getByRole("button", {
|
(screen.getByRole("button", {
|
||||||
name: "Import credential",
|
name: "Import credential",
|
||||||
}) as HTMLButtonElement).disabled,
|
}) as HTMLButtonElement).disabled,
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
|
const deployRow = screen.getByText("Deploy key").closest("tr");
|
||||||
|
if (!deployRow) throw new Error("missing deploy credential row");
|
||||||
expect(
|
expect(
|
||||||
(screen.getByRole("button", {
|
(within(deployRow).getByRole("button", {
|
||||||
name: "Add pinned key",
|
name: "Rotate",
|
||||||
}) as HTMLButtonElement).disabled,
|
}) as HTMLButtonElement).disabled,
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
|
const addHost = screen.getByRole("button", { name: "Add pinned key" });
|
||||||
|
expect((addHost as HTMLButtonElement).disabled).toBe(false);
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Generating…" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Generating…" }));
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await fireEvent.click(addHost);
|
||||||
|
const hostDraft = screen.getByRole("textbox", { name: "Host trust id" });
|
||||||
|
await fireEvent.input(hostDraft, { target: { value: "draft-host" } });
|
||||||
|
hostDraft.focus();
|
||||||
|
|
||||||
createResponse.resolve(Response.json({
|
createResponse.resolve(Response.json({
|
||||||
...deployCredential,
|
...deployCredential,
|
||||||
credential_id: "generated",
|
credential_id: "generated",
|
||||||
@@ -190,7 +205,10 @@ test("fences a deferred submission without disabling unrelated controls and rest
|
|||||||
current_revision: 1,
|
current_revision: 1,
|
||||||
}));
|
}));
|
||||||
expect(await screen.findByText("Generated key")).not.toBeNull();
|
expect(await screen.findByText("Generated key")).not.toBeNull();
|
||||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
expect(screen.getByRole("heading", { name: "Add pinned host key" })).not
|
||||||
|
.toBeNull();
|
||||||
|
expect((hostDraft as HTMLInputElement).value).toBe("draft-host");
|
||||||
|
expect(document.activeElement).toBe(hostDraft);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,14 +249,96 @@ test("keeps a deferred row failure on that row while unrelated actions remain en
|
|||||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => {
|
test("rerendering for another Workspace resets local state and fences stale responses", async () => {
|
||||||
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
|
const stalePublicKey = deferred<Response>();
|
||||||
Promise.resolve(Response.json({
|
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/api/w/workspace-1/")) return stalePublicKey.promise;
|
||||||
|
if (url.includes("/api/w/workspace-2/")) {
|
||||||
|
return Promise.resolve(new Response(null, { status: 500 }));
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request: ${url}`);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const { rerender } = renderPage();
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Generate credential" }),
|
||||||
|
);
|
||||||
|
await fireEvent.input(
|
||||||
|
screen.getByRole("textbox", { name: "Credential id" }),
|
||||||
|
{
|
||||||
|
target: { value: "workspace-a-draft" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const workspaceARow = screen.getByText("Deploy key").closest("tr");
|
||||||
|
if (!workspaceARow) throw new Error("missing Workspace A credential row");
|
||||||
|
await fireEvent.click(
|
||||||
|
within(workspaceARow).getByRole("button", { name: "Copy public key" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const workspaceBData = {
|
||||||
|
...pageData("workspace-2"),
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
...defaultCredential,
|
||||||
|
workspace_id: "workspace-2",
|
||||||
|
name: "Workspace B default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...deployCredential,
|
||||||
|
workspace_id: "workspace-2",
|
||||||
|
credential_id: "workspace-b-deploy",
|
||||||
|
name: "Workspace B deploy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
publicKeys: [],
|
||||||
|
hostTrusts: [{
|
||||||
...hostTrust,
|
...hostTrust,
|
||||||
host_key: "ssh-ed25519 AAAA-new",
|
workspace_id: "workspace-2",
|
||||||
fingerprint: "SHA256:host-new",
|
host_trust_id: "workspace-b-host",
|
||||||
current_revision: 5,
|
hostname: "git.example.test",
|
||||||
}))
|
}],
|
||||||
|
accessProjection: {
|
||||||
|
workspace_id: "workspace-2",
|
||||||
|
config_revision: 7,
|
||||||
|
projection_digest: "sha256:workspace-b",
|
||||||
|
bindings: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await rerender(pageProps(workspaceBData));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Workspace B deploy")).not.toBeNull();
|
||||||
|
expect(screen.queryByText("Deploy key")).toBeNull();
|
||||||
|
expect(screen.queryByDisplayValue("workspace-a-draft")).toBeNull();
|
||||||
|
expect(screen.getByText("git.example.test:22")).not.toBeNull();
|
||||||
|
|
||||||
|
stalePublicKey.resolve(Response.json({
|
||||||
|
credential_id: "deploy",
|
||||||
|
current_revision: 2,
|
||||||
|
public_key_algorithm: "ssh-ed25519",
|
||||||
|
public_key_fingerprint: "SHA256:deploy",
|
||||||
|
public_key: "ssh-ed25519 AAAA-stale",
|
||||||
|
}));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(screen.queryByText("Public key copied.")).toBeNull();
|
||||||
|
|
||||||
|
const workspaceBRow = screen.getByText("Workspace B deploy").closest("tr");
|
||||||
|
if (!workspaceBRow) throw new Error("missing Workspace B credential row");
|
||||||
|
await fireEvent.click(
|
||||||
|
within(workspaceBRow).getByRole("button", { name: "Copy public key" }),
|
||||||
|
);
|
||||||
|
expect(await within(workspaceBRow).findByRole("alert")).not.toBeNull();
|
||||||
|
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
|
||||||
|
"/api/w/workspace-2/settings/repository-access/credentials/workspace-b-deploy/public-key",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => {
|
||||||
|
const rotateResponse = deferred<Response>();
|
||||||
|
const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||||
|
rotateResponse.promise
|
||||||
);
|
);
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -272,6 +372,27 @@ test("locks a rotation to its host and port and discloses explicit and automatic
|
|||||||
expect(body.hostname).toBe("github.com");
|
expect(body.hostname).toBe("github.com");
|
||||||
expect(body.port).toBe(22);
|
expect(body.port).toBe(22);
|
||||||
expect(body.expected_revision).toBe(4);
|
expect(body.expected_revision).toBe(4);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Add pinned key",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Generate credential",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
rotateResponse.resolve(Response.json({
|
||||||
|
...hostTrust,
|
||||||
|
host_key: "ssh-ed25519 AAAA-new",
|
||||||
|
fingerprint: "SHA256:host-new",
|
||||||
|
current_revision: 5,
|
||||||
|
}));
|
||||||
expect(await within(row).findByText(/Rotated the key for github.com:22/)).not
|
expect(await within(row).findByText(/Rotated the key for github.com:22/)).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user