Add browser save and update workflow
This commit is contained in:
@@ -191,6 +191,96 @@ function cloneTarget(target) {
|
|||||||
return target && typeof target === "object" ? { ...target } : null;
|
return target && typeof target === "object" ? { ...target } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneSavePlan(plan) {
|
||||||
|
if (!plan || typeof plan !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: plan.mode === "update" ? "update" : "save",
|
||||||
|
entryId: typeof plan.entryId === "string" ? plan.entryId : "",
|
||||||
|
title: typeof plan.title === "string" ? plan.title : "",
|
||||||
|
path: Array.isArray(plan.path) ? [...plan.path] : [],
|
||||||
|
username: typeof plan.username === "string" ? plan.username : "",
|
||||||
|
password: typeof plan.password === "string" ? plan.password : "",
|
||||||
|
url: typeof plan.url === "string" ? plan.url : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObservedCredential(observed) {
|
||||||
|
if (!observed || typeof observed !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const password = typeof observed.password === "string" ? observed.password.trim() : "";
|
||||||
|
const url = typeof observed.url === "string" ? observed.url.trim() : "";
|
||||||
|
if (!password || !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: typeof observed.title === "string" ? observed.title.trim() : "",
|
||||||
|
username: typeof observed.username === "string" ? observed.username.trim() : "",
|
||||||
|
password,
|
||||||
|
url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchHost(rawURL) {
|
||||||
|
if (typeof rawURL !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const trimmed = rawURL.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(trimmed).hostname.toLowerCase();
|
||||||
|
} catch (_error) {
|
||||||
|
return trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultObservedTitle(observed) {
|
||||||
|
if (observed?.title) {
|
||||||
|
return observed.title;
|
||||||
|
}
|
||||||
|
return matchHost(observed?.url) || "Browser Login";
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePlanForObservedLogin(observed, matches) {
|
||||||
|
const normalized = normalizeObservedCredential(observed);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const targetHost = matchHost(normalized.url);
|
||||||
|
const exact = Array.isArray(matches) ? matches.find((match) =>
|
||||||
|
typeof match?.id === "string" &&
|
||||||
|
String(match?.username || "").trim().toLowerCase() === normalized.username.toLowerCase() &&
|
||||||
|
matchHost(match?.url || "") === targetHost
|
||||||
|
) : null;
|
||||||
|
if (exact) {
|
||||||
|
return {
|
||||||
|
mode: "update",
|
||||||
|
entryId: exact.id,
|
||||||
|
title: exact.title || defaultObservedTitle(normalized),
|
||||||
|
path: Array.isArray(exact.path) ? [...exact.path] : [],
|
||||||
|
username: normalized.username,
|
||||||
|
password: normalized.password,
|
||||||
|
url: normalized.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fallbackPath = Array.isArray(matches) && matches.length > 0 && Array.isArray(matches[0]?.path)
|
||||||
|
? [...matches[0].path]
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
mode: "save",
|
||||||
|
entryId: "",
|
||||||
|
title: defaultObservedTitle(normalized),
|
||||||
|
path: fallbackPath,
|
||||||
|
username: normalized.username,
|
||||||
|
password: normalized.password,
|
||||||
|
url: normalized.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePageState(state) {
|
function normalizePageState(state) {
|
||||||
return {
|
return {
|
||||||
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
||||||
@@ -207,6 +297,7 @@ function normalizePageState(state) {
|
|||||||
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
||||||
pendingTarget: cloneTarget(state?.pendingTarget),
|
pendingTarget: cloneTarget(state?.pendingTarget),
|
||||||
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
||||||
|
pendingSave: cloneSavePlan(state?.pendingSave),
|
||||||
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
||||||
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
||||||
};
|
};
|
||||||
@@ -228,6 +319,7 @@ function defaultPageState(tabId, pageUrl) {
|
|||||||
pendingEntryId: "",
|
pendingEntryId: "",
|
||||||
pendingTarget: null,
|
pendingTarget: null,
|
||||||
pendingMessage: "",
|
pendingMessage: "",
|
||||||
|
pendingSave: null,
|
||||||
lastFilledEntryId: "",
|
lastFilledEntryId: "",
|
||||||
updatedAt: 0
|
updatedAt: 0
|
||||||
});
|
});
|
||||||
@@ -347,6 +439,12 @@ function actionPresentationForState(state) {
|
|||||||
badgeText = "!";
|
badgeText = "!";
|
||||||
color = "#9f5f0e";
|
color = "#9f5f0e";
|
||||||
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
||||||
|
} else if (state.pendingSave) {
|
||||||
|
badgeText = "S";
|
||||||
|
color = "#255f4a";
|
||||||
|
title = state.pendingSave.mode === "update"
|
||||||
|
? `KeePassGO can update ${state.pendingSave.title || "this login"}`
|
||||||
|
: "KeePassGO can save the submitted login";
|
||||||
} else if (!state.configured) {
|
} else if (!state.configured) {
|
||||||
title = "Configure KeePassGO Browser in extension settings";
|
title = "Configure KeePassGO Browser in extension settings";
|
||||||
} else if (!state.success) {
|
} else if (!state.success) {
|
||||||
@@ -663,12 +761,64 @@ async function refreshActivePage(options = {}) {
|
|||||||
return refreshPageState(page.tabId, page.url, options);
|
return refreshPageState(page.tabId, page.url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveObservedLogin(tabId, selectedMatch = null) {
|
||||||
|
if (!Number.isInteger(tabId)) {
|
||||||
|
throw new Error("No active tab is available.");
|
||||||
|
}
|
||||||
|
const tab = await tabsGet(tabId);
|
||||||
|
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
|
||||||
|
let state = await getPageState(tabId, pageUrl);
|
||||||
|
const pendingSave = cloneSavePlan(state.pendingSave);
|
||||||
|
if (!pendingSave) {
|
||||||
|
throw new Error("There is no pending login to save.");
|
||||||
|
}
|
||||||
|
const request = {
|
||||||
|
action: "save-login",
|
||||||
|
title: pendingSave.title,
|
||||||
|
username: pendingSave.username,
|
||||||
|
password: pendingSave.password,
|
||||||
|
url: pendingSave.url
|
||||||
|
};
|
||||||
|
if (selectedMatch && typeof selectedMatch === "object") {
|
||||||
|
if (pendingSave.mode === "update" && typeof selectedMatch.id === "string" && selectedMatch.id.trim()) {
|
||||||
|
request.entryId = selectedMatch.id.trim();
|
||||||
|
request.title = String(selectedMatch.title || pendingSave.title || "").trim();
|
||||||
|
} else if (Array.isArray(selectedMatch.path) && selectedMatch.path.length > 0) {
|
||||||
|
request.path = [...selectedMatch.path];
|
||||||
|
}
|
||||||
|
} else if (pendingSave.mode === "update" && pendingSave.entryId) {
|
||||||
|
request.entryId = pendingSave.entryId;
|
||||||
|
} else if (pendingSave.path.length > 0) {
|
||||||
|
request.path = [...pendingSave.path];
|
||||||
|
}
|
||||||
|
const settings = await loadSettings();
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
throw new Error("API token is not configured.");
|
||||||
|
}
|
||||||
|
const response = await connectNative({
|
||||||
|
...request,
|
||||||
|
bearerToken: settings.bearerToken
|
||||||
|
});
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "KeePassGO did not save the submitted login.");
|
||||||
|
}
|
||||||
|
state = await setPageState(tabId, {
|
||||||
|
...state,
|
||||||
|
pendingSave: null,
|
||||||
|
error: "",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
await refreshPageState(tabId, pageUrl, { force: true });
|
||||||
|
return { state };
|
||||||
|
}
|
||||||
|
|
||||||
const backgroundTestExports = {
|
const backgroundTestExports = {
|
||||||
normalizePageState,
|
normalizePageState,
|
||||||
actionPresentationForState,
|
actionPresentationForState,
|
||||||
shouldReuseMatches,
|
shouldReuseMatches,
|
||||||
shouldContinueWatchingState,
|
shouldContinueWatchingState,
|
||||||
tokenPendingApprovalCount,
|
tokenPendingApprovalCount,
|
||||||
|
savePlanForObservedLogin,
|
||||||
defaultSettings
|
defaultSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -723,6 +873,33 @@ if (isNodeTestEnv) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "keepassgo-observed-login":
|
||||||
|
if (Number.isInteger(sender?.tab?.id)) {
|
||||||
|
const targetState = await getPageState(sender.tab.id, sender.tab.url || "");
|
||||||
|
const nextSave = savePlanForObservedLogin(message.observed, targetState.matches);
|
||||||
|
sendResponse(await setPageState(sender.tab.id, {
|
||||||
|
...targetState,
|
||||||
|
pendingSave: nextSave,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResponse({ success: false, error: "No active tab is available." });
|
||||||
|
return;
|
||||||
|
case "keepassgo-save-login": {
|
||||||
|
const targetTabID = Number.isInteger(message?.tabId)
|
||||||
|
? message.tabId
|
||||||
|
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
|
||||||
|
const selectedMatch = message?.selectedMatch && typeof message.selectedMatch === "object"
|
||||||
|
? {
|
||||||
|
id: String(message.selectedMatch.id || "").trim(),
|
||||||
|
title: String(message.selectedMatch.title || "").trim(),
|
||||||
|
path: Array.isArray(message.selectedMatch.path) ? message.selectedMatch.path : []
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
sendResponse({ success: true, ...(await saveObservedLogin(targetTabID, selectedMatch)) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "keepassgo-page-ready":
|
case "keepassgo-page-ready":
|
||||||
if (Number.isInteger(sender?.tab?.id)) {
|
if (Number.isInteger(sender?.tab?.id)) {
|
||||||
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
||||||
|
|||||||
@@ -70,3 +70,62 @@ test("shouldContinueWatchingState keeps polling locked login pages", () => {
|
|||||||
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
||||||
assert.equal(background.defaultSettings.bearerToken, "");
|
assert.equal(background.defaultSettings.bearerToken, "");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("savePlanForObservedLogin prefers updating an exact username match", () => {
|
||||||
|
const plan = background.savePlanForObservedLogin({
|
||||||
|
username: "dannyocean",
|
||||||
|
password: "bellagio-safe",
|
||||||
|
url: "https://vault.example.invalid/login"
|
||||||
|
}, [
|
||||||
|
{
|
||||||
|
id: "vault-console",
|
||||||
|
title: "Vault Console",
|
||||||
|
username: "dannyocean",
|
||||||
|
url: "vault.example.invalid",
|
||||||
|
path: ["Crew", "Internet"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bellagio-backup",
|
||||||
|
title: "Bellagio Backup",
|
||||||
|
username: "rustyryan",
|
||||||
|
url: "vault.example.invalid",
|
||||||
|
path: ["Crew", "Internet"]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(plan, {
|
||||||
|
mode: "update",
|
||||||
|
entryId: "vault-console",
|
||||||
|
title: "Vault Console",
|
||||||
|
path: ["Crew", "Internet"],
|
||||||
|
username: "dannyocean",
|
||||||
|
password: "bellagio-safe",
|
||||||
|
url: "https://vault.example.invalid/login"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("savePlanForObservedLogin falls back to saving into the current page group", () => {
|
||||||
|
const plan = background.savePlanForObservedLogin({
|
||||||
|
username: "linuscaldwell",
|
||||||
|
password: "yellow-chip",
|
||||||
|
url: "https://vault.example.invalid/login"
|
||||||
|
}, [
|
||||||
|
{
|
||||||
|
id: "vault-console",
|
||||||
|
title: "Vault Console",
|
||||||
|
username: "dannyocean",
|
||||||
|
url: "vault.example.invalid",
|
||||||
|
path: ["Crew", "Internet"]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(plan, {
|
||||||
|
mode: "save",
|
||||||
|
entryId: "",
|
||||||
|
title: "vault.example.invalid",
|
||||||
|
path: ["Crew", "Internet"],
|
||||||
|
username: "linuscaldwell",
|
||||||
|
password: "yellow-chip",
|
||||||
|
url: "https://vault.example.invalid/login"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -396,6 +396,22 @@ function fillCredential(credential, targetDescriptor) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submittedCredential(candidate, rawURL) {
|
||||||
|
if (!candidate?.passwordInput) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const password = String(candidate.passwordInput.value || "").trim();
|
||||||
|
if (!password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: domainLabel(rawURL),
|
||||||
|
username: String(candidate.usernameInput?.value || "").trim(),
|
||||||
|
password,
|
||||||
|
url: String(rawURL || "").trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function domainLabel(rawURL) {
|
function domainLabel(rawURL) {
|
||||||
try {
|
try {
|
||||||
return new URL(rawURL).host || "";
|
return new URL(rawURL).host || "";
|
||||||
@@ -447,7 +463,8 @@ const contentTestExports = {
|
|||||||
fieldHintText,
|
fieldHintText,
|
||||||
scopeHintText,
|
scopeHintText,
|
||||||
hasAuthFlowSignals,
|
hasAuthFlowSignals,
|
||||||
authFlowCandidate
|
authFlowCandidate,
|
||||||
|
submittedCredential
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isNodeTestEnv) {
|
if (isNodeTestEnv) {
|
||||||
@@ -805,6 +822,23 @@ if (isNodeTestEnv) {
|
|||||||
scheduleRefresh(false);
|
scheduleRefresh(false);
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("submit", (event) => {
|
||||||
|
const form = event.target instanceof HTMLFormElement ? event.target : null;
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordInput = visibleInputs(form).find(isPasswordCandidate) || null;
|
||||||
|
const candidate = passwordInput ? authFlowCandidate(passwordInput) : null;
|
||||||
|
const observed = submittedCredential(candidate, window.location.href);
|
||||||
|
if (!observed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runtimeSend({
|
||||||
|
type: "keepassgo-observed-login",
|
||||||
|
observed
|
||||||
|
}).catch(() => null);
|
||||||
|
}, true);
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
if (!root.contains(event.target)) {
|
if (!root.contains(event.target)) {
|
||||||
chooserOpen = false;
|
chooserOpen = false;
|
||||||
|
|||||||
@@ -120,3 +120,17 @@ test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
|
|||||||
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
|
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
|
||||||
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
|
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("submittedCredential captures the pending browser save payload from a login candidate", () => {
|
||||||
|
const candidate = {
|
||||||
|
usernameInput: { value: "linuscaldwell" },
|
||||||
|
passwordInput: { value: "yellow-chip" }
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(content.submittedCredential(candidate, "https://bellagio.example.invalid/login"), {
|
||||||
|
title: "bellagio.example.invalid",
|
||||||
|
username: "linuscaldwell",
|
||||||
|
password: "yellow-chip",
|
||||||
|
url: "https://bellagio.example.invalid/login"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||||
</section>
|
</section>
|
||||||
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
||||||
|
<section id="save-card" class="save-card" hidden>
|
||||||
|
<div>
|
||||||
|
<h2>Save Submitted Login</h2>
|
||||||
|
<p id="save-message" class="subtle">KeePassGO can save this login.</p>
|
||||||
|
</div>
|
||||||
|
<button id="save-action" type="button">Save Login</button>
|
||||||
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Matches</h2>
|
<h2>Matches</h2>
|
||||||
<div id="matches" class="match-list"></div>
|
<div id="matches" class="match-list"></div>
|
||||||
|
|||||||
+87
-11
@@ -43,6 +43,12 @@ function matchSubtitle(match) {
|
|||||||
return parts.join(" · ") || "No username";
|
return parts.join(" · ") || "No username";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveCardLabel(pendingSave) {
|
||||||
|
return pendingSave?.mode === "update"
|
||||||
|
? `Update ${pendingSave.title || "Login"}`
|
||||||
|
: "Save Login";
|
||||||
|
}
|
||||||
|
|
||||||
function renderMatchList(root, matches, options = {}) {
|
function renderMatchList(root, matches, options = {}) {
|
||||||
const targetTabID = popupTabID();
|
const targetTabID = popupTabID();
|
||||||
const emptyMessage = options.emptyMessage || "No matching entries.";
|
const emptyMessage = options.emptyMessage || "No matching entries.";
|
||||||
@@ -75,19 +81,23 @@ function renderMatchList(root, matches, options = {}) {
|
|||||||
row.appendChild(quality);
|
row.appendChild(quality);
|
||||||
row.addEventListener("click", async () => {
|
row.addEventListener("click", async () => {
|
||||||
row.disabled = true;
|
row.disabled = true;
|
||||||
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
|
||||||
try {
|
try {
|
||||||
const result = await runtimeSend({
|
if (typeof options.onSelect === "function") {
|
||||||
type: "keepassgo-fill-entry",
|
await options.onSelect(match, targetTabID);
|
||||||
entryId: match.id,
|
} else {
|
||||||
tabId: targetTabID
|
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
||||||
});
|
const result = await runtimeSend({
|
||||||
if (!result?.success) {
|
type: "keepassgo-fill-entry",
|
||||||
throw new Error(result?.error || "Fill failed.");
|
entryId: match.id,
|
||||||
|
tabId: targetTabID
|
||||||
|
});
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || "Fill failed.");
|
||||||
|
}
|
||||||
|
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||||
}
|
}
|
||||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
|
||||||
} finally {
|
} finally {
|
||||||
row.disabled = false;
|
row.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -100,7 +110,30 @@ function renderMatches(state) {
|
|||||||
const emptyMessage = state.pageHasLoginForm
|
const emptyMessage = state.pageHasLoginForm
|
||||||
? "No matching entries for this page."
|
? "No matching entries for this page."
|
||||||
: "No login fields detected on this page.";
|
: "No login fields detected on this page.";
|
||||||
renderMatchList(document.getElementById("matches"), state.matches, { emptyMessage });
|
const root = document.getElementById("matches");
|
||||||
|
if (state.pendingSave) {
|
||||||
|
renderMatchList(root, state.matches, {
|
||||||
|
emptyMessage,
|
||||||
|
onSelect: async (match, targetTabID) => {
|
||||||
|
const result = await runtimeSend({
|
||||||
|
type: "keepassgo-save-login",
|
||||||
|
tabId: targetTabID,
|
||||||
|
selectedMatch: {
|
||||||
|
id: match.id,
|
||||||
|
title: match.title,
|
||||||
|
path: Array.isArray(match.path) ? match.path : []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || "Save failed.");
|
||||||
|
}
|
||||||
|
setStatus("Saved", `${state.pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
|
||||||
|
document.getElementById("save-card").hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderMatchList(root, state.matches, { emptyMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSearchResults(results, query) {
|
function renderSearchResults(results, query) {
|
||||||
@@ -135,6 +168,46 @@ function renderPageHint(state) {
|
|||||||
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPendingSave(state) {
|
||||||
|
const card = document.getElementById("save-card");
|
||||||
|
const message = document.getElementById("save-message");
|
||||||
|
const action = document.getElementById("save-action");
|
||||||
|
const pendingSave = state.pendingSave;
|
||||||
|
if (!pendingSave) {
|
||||||
|
card.hidden = true;
|
||||||
|
action.onclick = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.hidden = false;
|
||||||
|
action.textContent = saveCardLabel(pendingSave);
|
||||||
|
if (pendingSave.mode === "update") {
|
||||||
|
message.textContent = `KeePassGO can update ${pendingSave.title || "this login"} with the submitted password.`;
|
||||||
|
} else if (Array.isArray(pendingSave.path) && pendingSave.path.length > 0) {
|
||||||
|
message.textContent = `KeePassGO can save this login in ${pendingSave.path.join(" / ")}. Search the vault to choose a different group if needed.`;
|
||||||
|
} else {
|
||||||
|
message.textContent = "Search the vault below to choose a group for this submitted login.";
|
||||||
|
}
|
||||||
|
action.disabled = pendingSave.mode !== "update" && (!Array.isArray(pendingSave.path) || pendingSave.path.length === 0);
|
||||||
|
action.onclick = async () => {
|
||||||
|
action.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runtimeSend({
|
||||||
|
type: "keepassgo-save-login",
|
||||||
|
tabId: popupTabID()
|
||||||
|
});
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || "Save failed.");
|
||||||
|
}
|
||||||
|
setStatus("Saved", `${pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
|
||||||
|
card.hidden = true;
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("Save failed", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
} finally {
|
||||||
|
action.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function popupTabID() {
|
function popupTabID() {
|
||||||
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
||||||
if (rawValue === null) {
|
if (rawValue === null) {
|
||||||
@@ -183,6 +256,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||||
renderPageHint(state);
|
renderPageHint(state);
|
||||||
|
renderPendingSave(state);
|
||||||
|
|
||||||
if (!state.configured) {
|
if (!state.configured) {
|
||||||
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||||
@@ -208,6 +282,8 @@ async function main() {
|
|||||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||||
if (!state.pageHasLoginForm) {
|
if (!state.pageHasLoginForm) {
|
||||||
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
||||||
|
} else if (state.pendingSave) {
|
||||||
|
setStatus("Save submitted login", state.pendingSave.mode === "update" ? `Update ${state.pendingSave.title || "this login"} or pick a different target below.` : "Save this submitted login or search below to choose a target entry.", "ready");
|
||||||
} else if (count === 0) {
|
} else if (count === 0) {
|
||||||
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -100,6 +100,18 @@ h2 {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
border: 1px solid #c5dccf;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
|||||||
@@ -134,6 +134,31 @@ Expected behavior:
|
|||||||
and turns the locked affordance back into live matches without requiring a
|
and turns the locked affordance back into live matches without requiring a
|
||||||
page reload.
|
page reload.
|
||||||
|
|
||||||
|
## Save And Update Workflow
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- After the user submits a login form, the browser extension should help store
|
||||||
|
that credential instead of forcing the user back into KeePassGO manually.
|
||||||
|
- If KeePassGO already has a matching entry for that site and username, the
|
||||||
|
popup should offer an update.
|
||||||
|
- If the user is creating a new login, the popup should let the user save it
|
||||||
|
into a relevant vault group without leaving the browser.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- Submitted login forms queue a pending browser save/update state for the
|
||||||
|
active tab.
|
||||||
|
- The popup shows that pending save/update state prominently instead of hiding
|
||||||
|
it behind page matches alone.
|
||||||
|
- When KeePassGO finds an exact browser match for the submitted username and
|
||||||
|
site, the popup offers an `Update` action for that entry.
|
||||||
|
- When there is no exact entry match, the popup offers a `Save` action using a
|
||||||
|
relevant group path from the current page matches or a user-selected search
|
||||||
|
result.
|
||||||
|
- The browser save/update action writes through KeePassGO's existing secure
|
||||||
|
gRPC mutation API and stays scoped to the browser token's allowed groups.
|
||||||
|
|
||||||
For extension-side regression checks, run:
|
For extension-side regression checks, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package browserbridge
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -28,11 +31,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
BearerToken string `json:"bearerToken,omitempty"`
|
BearerToken string `json:"bearerToken,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
EntryID string `json:"entryId,omitempty"`
|
EntryID string `json:"entryId,omitempty"`
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Path []string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Response struct {
|
type Response struct {
|
||||||
@@ -81,10 +88,13 @@ type Client interface {
|
|||||||
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||||
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
|
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
|
||||||
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||||
|
UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Browser string
|
type Browser string
|
||||||
|
|
||||||
|
type actionHandler func(context.Context, Client, Request, string) Response
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BrowserFirefox Browser = "firefox"
|
BrowserFirefox Browser = "firefox"
|
||||||
BrowserChrome Browser = "chrome"
|
BrowserChrome Browser = "chrome"
|
||||||
@@ -166,43 +176,70 @@ func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Cli
|
|||||||
return Response{Success: false, Error: err.Error()}
|
return Response{Success: false, Error: err.Error()}
|
||||||
}
|
}
|
||||||
action := strings.TrimSpace(req.Action)
|
action := strings.TrimSpace(req.Action)
|
||||||
switch action {
|
handler, ok := actionHandlers[action]
|
||||||
case "status":
|
if !ok {
|
||||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
|
||||||
if err != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: status, Version: responseVersion}
|
|
||||||
case "find-logins":
|
|
||||||
matches, err := findMatches(ctx, client, req.URL)
|
|
||||||
if err != nil {
|
|
||||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
|
||||||
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
|
||||||
}
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
|
|
||||||
case "search-logins":
|
|
||||||
results, err := searchEntries(ctx, client, req.Query)
|
|
||||||
if err != nil {
|
|
||||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
|
||||||
return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
|
|
||||||
}
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), SearchResults: results, Version: responseVersion}
|
|
||||||
case "get-login":
|
|
||||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
|
||||||
if err != nil {
|
|
||||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: status}
|
|
||||||
}
|
|
||||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
||||||
}
|
|
||||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
|
|
||||||
default:
|
|
||||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||||
}
|
}
|
||||||
|
return handler(ctx, client, req, conn.GRPCAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionHandlers = map[string]actionHandler{
|
||||||
|
"status": handleStatusAction,
|
||||||
|
"find-logins": handleFindLoginsAction,
|
||||||
|
"search-logins": handleSearchLoginsAction,
|
||||||
|
"get-login": handleGetLoginAction,
|
||||||
|
"save-login": handleSaveLoginAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStatusAction(ctx context.Context, client Client, _ Request, grpcAddress string) Response {
|
||||||
|
status, err := statusResponse(ctx, client, grpcAddress)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: status, Version: responseVersion}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFindLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||||
|
matches, err := findMatches(ctx, client, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||||
|
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(grpcAddress), Matches: matches, Version: responseVersion}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSearchLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||||
|
results, err := searchEntries(ctx, client, req.Query)
|
||||||
|
if err != nil {
|
||||||
|
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||||
|
return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(grpcAddress), SearchResults: results, Version: responseVersion}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||||
|
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(grpcAddress), Credential: credential, Version: responseVersion}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSaveLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||||
|
if err := saveLogin(ctx, client, req); err != nil {
|
||||||
|
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: availableStatus(grpcAddress), Version: responseVersion}
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnectedStatus(addr string) *Status {
|
func disconnectedStatus(addr string) *Status {
|
||||||
@@ -276,6 +313,95 @@ func loadCredential(ctx context.Context, client Client, entryID, rawURL string)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveLogin(ctx context.Context, client Client, req Request) error {
|
||||||
|
if strings.TrimSpace(req.Password) == "" {
|
||||||
|
return fmt.Errorf("browser save requires a password")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.EntryID) != "" {
|
||||||
|
entries, err := client.ListEntries(ctx, nil, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing := findEntry(entries, req.EntryID)
|
||||||
|
if existing == nil {
|
||||||
|
return fmt.Errorf("entry %q was not found", strings.TrimSpace(req.EntryID))
|
||||||
|
}
|
||||||
|
entry := cloneEntry(existing)
|
||||||
|
entry.Title = coalesceTitle(req.Title, existing.Title, req.URL)
|
||||||
|
entry.Username = strings.TrimSpace(req.Username)
|
||||||
|
entry.Password = strings.TrimSpace(req.Password)
|
||||||
|
entry.Url = strings.TrimSpace(req.URL)
|
||||||
|
_, err = client.UpsertEntry(ctx, entry)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := append([]string(nil), req.Path...)
|
||||||
|
if len(path) == 0 {
|
||||||
|
return fmt.Errorf("browser save requires a target group path")
|
||||||
|
}
|
||||||
|
entry := &keepassgov1.Entry{
|
||||||
|
Id: newBrowserEntryID(),
|
||||||
|
Title: coalesceTitle(req.Title, "", req.URL),
|
||||||
|
Username: strings.TrimSpace(req.Username),
|
||||||
|
Password: strings.TrimSpace(req.Password),
|
||||||
|
Url: strings.TrimSpace(req.URL),
|
||||||
|
Path: path,
|
||||||
|
Fields: map[string]string{},
|
||||||
|
}
|
||||||
|
_, err := client.UpsertEntry(ctx, entry)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func findEntry(entries []*keepassgov1.Entry, id string) *keepassgov1.Entry {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.GetId() == strings.TrimSpace(id) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneEntry(entry *keepassgov1.Entry) *keepassgov1.Entry {
|
||||||
|
if entry == nil {
|
||||||
|
return &keepassgov1.Entry{Fields: map[string]string{}}
|
||||||
|
}
|
||||||
|
fields := make(map[string]string, len(entry.GetFields()))
|
||||||
|
for key, value := range entry.GetFields() {
|
||||||
|
fields[key] = value
|
||||||
|
}
|
||||||
|
return &keepassgov1.Entry{
|
||||||
|
Id: entry.GetId(),
|
||||||
|
Title: entry.GetTitle(),
|
||||||
|
Username: entry.GetUsername(),
|
||||||
|
Password: entry.GetPassword(),
|
||||||
|
Url: entry.GetUrl(),
|
||||||
|
Notes: entry.GetNotes(),
|
||||||
|
Tags: append([]string(nil), entry.GetTags()...),
|
||||||
|
Path: append([]string(nil), entry.GetPath()...),
|
||||||
|
Fields: fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesceTitle(title, fallback, rawURL string) string {
|
||||||
|
if trimmed := strings.TrimSpace(title); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||||
|
}
|
||||||
|
return "Browser Login"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBrowserEntryID() string {
|
||||||
|
var buf [16]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return fmt.Sprintf("browser-%d", os.Getpid())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
func searchEntries(ctx context.Context, client Client, query string) ([]Match, error) {
|
func searchEntries(ctx context.Context, client Client, query string) ([]Match, error) {
|
||||||
resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query))
|
resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -170,6 +170,89 @@ func TestHandleRequestSearchLogins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestSaveLoginUpdatesExistingEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{
|
||||||
|
entries: []*keepassgov1.Entry{
|
||||||
|
{
|
||||||
|
Id: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "old-password",
|
||||||
|
Url: "https://vault.example.invalid/login",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
Fields: map[string]string{
|
||||||
|
"URL1": "vault.example.invalid",
|
||||||
|
"X-Role": "inside-man",
|
||||||
|
},
|
||||||
|
Tags: []string{"vault"},
|
||||||
|
Notes: "Original notes stay intact.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "save-login",
|
||||||
|
BearerToken: "secret",
|
||||||
|
EntryID: "vault-console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "new-password",
|
||||||
|
URL: "https://vault.example.invalid/login",
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest(save-login update) success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if client.upserted == nil {
|
||||||
|
t.Fatal("HandleRequest(save-login update) did not upsert an entry")
|
||||||
|
}
|
||||||
|
if got := client.upserted.Id; got != "vault-console" {
|
||||||
|
t.Fatalf("upserted.Id = %q, want vault-console", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Password; got != "new-password" {
|
||||||
|
t.Fatalf("upserted.Password = %q, want new-password", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Fields["X-Role"]; got != "inside-man" {
|
||||||
|
t.Fatalf("upserted.Fields[X-Role] = %q, want inside-man", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Notes; got != "Original notes stay intact." {
|
||||||
|
t.Fatalf("upserted.Notes = %q, want original notes", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestSaveLoginCreatesNewEntryInChosenPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &fakeClient{}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "save-login",
|
||||||
|
BearerToken: "secret",
|
||||||
|
Title: "Bellagio Login",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "yellow-chip",
|
||||||
|
URL: "https://bellagio.example.invalid/login",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
}, "", client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest(save-login create) success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if client.upserted == nil {
|
||||||
|
t.Fatal("HandleRequest(save-login create) did not upsert an entry")
|
||||||
|
}
|
||||||
|
if got := client.upserted.Title; got != "Bellagio Login" {
|
||||||
|
t.Fatalf("upserted.Title = %q, want Bellagio Login", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Username; got != "linuscaldwell" {
|
||||||
|
t.Fatalf("upserted.Username = %q, want linuscaldwell", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Path; !slices.Equal(got, []string{"Crew", "Internet"}) {
|
||||||
|
t.Fatalf("upserted.Path = %v, want [Crew Internet]", got)
|
||||||
|
}
|
||||||
|
if got := client.upserted.Id; got == "" {
|
||||||
|
t.Fatal("upserted.Id = empty, want generated id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -332,10 +415,12 @@ type fakeClient struct {
|
|||||||
matches []*keepassgov1.BrowserLoginMatch
|
matches []*keepassgov1.BrowserLoginMatch
|
||||||
entries []*keepassgov1.Entry
|
entries []*keepassgov1.Entry
|
||||||
credential *keepassgov1.GetBrowserCredentialResponse
|
credential *keepassgov1.GetBrowserCredentialResponse
|
||||||
|
upserted *keepassgov1.Entry
|
||||||
err error
|
err error
|
||||||
matchesErr error
|
matchesErr error
|
||||||
entriesErr error
|
entriesErr error
|
||||||
credentialErr error
|
credentialErr error
|
||||||
|
upsertErr error
|
||||||
statusCalls int
|
statusCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,3 +512,11 @@ func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*kee
|
|||||||
}
|
}
|
||||||
return f.credential, nil
|
return f.credential, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) UpsertEntry(_ context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
|
||||||
|
if f.upsertErr != nil {
|
||||||
|
return nil, f.upsertErr
|
||||||
|
}
|
||||||
|
f.upserted = entry
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,3 +82,11 @@ func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL
|
|||||||
PageUrl: strings.TrimSpace(pageURL),
|
PageUrl: strings.TrimSpace(pageURL),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) UpsertEntry(ctx context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
|
||||||
|
resp, err := c.client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{Entry: entry})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.GetEntry(), nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user