fix: a site allowed without Remember stays connected when the background worker idles (closes #526)
A connection allowed without "Remember my choice for this site" lived in a module-level object in the background, so it ended the first time Chrome stopped the idle worker. Each such connection is now one item in the browser's session storage, which outlives the worker and is emptied when the browser closes, read and written through new wrappers in src/shared/browserApi.js. Removal in Settings, address switch, address removal and wallet removal still end it; Settings now waits for the removal before redrawing. manifest/firefox.json now declares a minimum of Firefox 115.0, the first with session storage. Judgement call: address and wallet removal still notify the background without waiting. Model: opus-5-5
This commit was merged in pull request #568.
This commit is contained in:
@@ -571,9 +571,12 @@ the worker stopping. It starts Chromium itself, speaks the DevTools protocol
|
|||||||
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
|
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
|
||||||
approvals it requires the worker to stop on its own with nothing pending, so a
|
approvals it requires the worker to stop on its own with nothing pending, so a
|
||||||
run in which something else keeps the worker running fails. The site is
|
run in which something else keeps the worker running fails. The site is
|
||||||
connected and the RPC endpoint set through the extension's own screens, against
|
connected, without "Remember my choice for this site", and the RPC endpoint set
|
||||||
the loopback dApp page and stub node the Firefox suite also uses
|
through the extension's own screens, against the loopback dApp page and stub
|
||||||
(`tests/e2e/dapp.js`). Unlike the suite above, it does not fail on page errors.
|
node the Firefox suite also uses (`tests/e2e/dapp.js`). Once the worker has
|
||||||
|
stopped, the run requires the site still to be told the address
|
||||||
|
([#526](https://git.eeqj.de/sneak/AutistMask/issues/526)). Unlike the suite
|
||||||
|
above, it does not fail on page errors.
|
||||||
|
|
||||||
### Firefox (`make test-e2e-firefox`)
|
### Firefox (`make test-e2e-firefox`)
|
||||||
|
|
||||||
@@ -857,7 +860,11 @@ on the next event. Two consequences shape every recurring job in the background:
|
|||||||
- Module-level variables do not survive either. Anything that must be remembered
|
- Module-level variables do not survive either. Anything that must be remembered
|
||||||
across a restart goes in extension storage. `localStorage` does not exist in a
|
across a restart goes in extension storage. `localStorage` does not exist in a
|
||||||
service worker at all — the one remaining user of it, `src/shared/ens.js`,
|
service worker at all — the one remaining user of it, `src/shared/ens.js`,
|
||||||
runs only in the popup and is marked as such.
|
runs only in the popup and is marked as such. The sites allowed without
|
||||||
|
"Remember my choice for this site" are kept in the browser's session storage
|
||||||
|
(`storage.session`), which outlives the worker but not the browser, so such a
|
||||||
|
connection lasts until it is ended or the browser closes. Firefox has session
|
||||||
|
storage from version 115, the minimum `manifest/firefox.json` declares.
|
||||||
|
|
||||||
Pending approvals are the exception: an approval waiting for the user, and the
|
Pending approvals are the exception: an approval waiting for the user, and the
|
||||||
site's request waiting on it, live only in the worker's memory, and only the
|
site's request waiting on it, live only in the worker's memory, and only the
|
||||||
@@ -1936,7 +1943,7 @@ view would leave a wallet one click from deletion.
|
|||||||
under any address, with remove buttons
|
under any address, with remove buttons
|
||||||
- Connected Sites: the origins of the sites allowed without "Remember my
|
- Connected Sites: the origins of the sites allowed without "Remember my
|
||||||
choice" that are still connected, with remove buttons. Only the background
|
choice" that are still connected, with remove buttons. Only the background
|
||||||
holds these, in memory, and Settings asks it for them with
|
holds these, in session storage, and Settings asks it for them with
|
||||||
`AUTISTMASK_GET_CONNECTED_SITES`
|
`AUTISTMASK_GET_CONNECTED_SITES`
|
||||||
- Denied Sites: the origins remembered as denied, under any address, with
|
- Denied Sites: the origins remembered as denied, under any address, with
|
||||||
remove buttons
|
remove buttons
|
||||||
@@ -1955,9 +1962,10 @@ view would leave a wallet one click from deletion.
|
|||||||
its origin is dropped from Allowed Sites under every address, and
|
its origin is dropped from Allowed Sites under every address, and
|
||||||
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
|
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
|
||||||
without "Remember" from that origin, under any address, and send
|
without "Remember" from that origin, under any address, and send
|
||||||
`accountsChanged` with an empty list to the open tabs of that origin. The
|
`accountsChanged` with an empty list to the open tabs of that origin.
|
||||||
same host under another scheme or port is another site and is left alone.
|
Settings waits for the background's answer before it draws the lists
|
||||||
Only the extension's own pages may send either message
|
again. The same host under another scheme or port is another site and is
|
||||||
|
left alone. Only the extension's own pages may send either message
|
||||||
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
||||||
nothing and tells the background nothing
|
nothing and tells the background nothing
|
||||||
- Ten clicks on the version → reveals the Debug well (no screen change)
|
- Ten clicks on the version → reveals the Debug well (no screen change)
|
||||||
@@ -2018,8 +2026,8 @@ view would leave a wallet one click from deletion.
|
|||||||
(`src/shared/walletDelete.js`)
|
(`src/shared/walletDelete.js`)
|
||||||
- Either way, every address the wallet held loses its site permissions of
|
- Either way, every address the wallet held loses its site permissions of
|
||||||
both kinds: the remembered ones in storage, and the connections approved
|
both kinds: the remembered ones in storage, and the connections approved
|
||||||
without "Remember", which only the background holds, in memory, and drops
|
without "Remember", which only the background holds, in session storage,
|
||||||
on `AUTISTMASK_ADDRESSES_REMOVED`
|
and drops on `AUTISTMASK_ADDRESSES_REMOVED`
|
||||||
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
|
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
|
||||||
try again." on the error line, nothing deleted
|
try again." on the error line, nothing deleted
|
||||||
- "I have lost my password" → **DeleteWalletLostPassword**
|
- "I have lost my password" → **DeleteWalletLostPassword**
|
||||||
@@ -2132,8 +2140,8 @@ view would leave a wallet one click from deletion.
|
|||||||
one in this wallet follows the splice.
|
one in this wallet follows the splice.
|
||||||
- The address loses its site permissions of both kinds, whether or not it was
|
- The address loses its site permissions of both kinds, whether or not it was
|
||||||
the active one: the remembered ones in storage, and any connection approved
|
the active one: the remembered ones in storage, and any connection approved
|
||||||
without "Remember", which only the background holds, in memory, and drops on
|
without "Remember", which only the background holds, in session storage, and
|
||||||
`AUTISTMASK_ADDRESSES_REMOVED`.
|
drops on `AUTISTMASK_ADDRESSES_REMOVED`.
|
||||||
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
|
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
|
||||||
fresh address rather than handing back the one just removed.
|
fresh address rather than handing back the one just removed.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"web_accessible_resources": ["src/content/inpage.js"],
|
"web_accessible_resources": ["src/content/inpage.js"],
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
"id": "autistmask@sneak.berlin"
|
"id": "autistmask@sneak.berlin",
|
||||||
|
"strict_min_version": "115.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-40
@@ -49,6 +49,9 @@ const {
|
|||||||
actionApi,
|
actionApi,
|
||||||
getPlatformInfo,
|
getPlatformInfo,
|
||||||
runtimeApi,
|
runtimeApi,
|
||||||
|
sessionStorageGet,
|
||||||
|
sessionStorageRemove,
|
||||||
|
sessionStorageSet,
|
||||||
tabsQuery,
|
tabsQuery,
|
||||||
tabsSendMessage,
|
tabsSendMessage,
|
||||||
windowsApi,
|
windowsApi,
|
||||||
@@ -61,12 +64,31 @@ const runtime = runtimeApi();
|
|||||||
const windowsNs = windowsApi();
|
const windowsNs = windowsApi();
|
||||||
const actionNs = actionApi();
|
const actionNs = actionApi();
|
||||||
|
|
||||||
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
// Connections the user allowed without "Remember my choice for this site".
|
||||||
|
// Each is one item in the browser's session storage, key
|
||||||
|
// origin + ":" + address and value true, and session storage holds nothing
|
||||||
|
// else. Session storage outlives this worker being stopped when idle and
|
||||||
|
// started again, so such a connection lasts until it is ended below or the
|
||||||
|
// browser closes. One item per connection means that making one is a single
|
||||||
|
// write, and two never overwrite each other.
|
||||||
//
|
//
|
||||||
// A site is its full origin (scheme://host[:port]), here and in the
|
// A site is its full origin (scheme://host[:port]), here and in the
|
||||||
// remembered allowedSites/deniedSites lists alike: a grant to
|
// remembered allowedSites/deniedSites lists alike: a grant to
|
||||||
// https://dapp.example says nothing about http://dapp.example or another port.
|
// https://dapp.example says nothing about http://dapp.example or another port.
|
||||||
const connectedSites = {};
|
// An origin can carry a port, so the address is what follows the key's last
|
||||||
|
// colon and the origin is what precedes it.
|
||||||
|
async function isConnected(origin, address) {
|
||||||
|
const key = origin + ":" + address;
|
||||||
|
const items = await sessionStorageGet(key);
|
||||||
|
return items[key] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End every connection allowed without "Remember" whose key `ends` returns
|
||||||
|
// true for.
|
||||||
|
async function endConnections(ends) {
|
||||||
|
const keys = Object.keys(await sessionStorageGet(null)).filter(ends);
|
||||||
|
if (keys.length > 0) await sessionStorageRemove(keys);
|
||||||
|
}
|
||||||
|
|
||||||
// Pending approval requests: { id: { origin, resolve } }
|
// Pending approval requests: { id: { origin, resolve } }
|
||||||
const pendingApprovals = {};
|
const pendingApprovals = {};
|
||||||
@@ -764,10 +786,10 @@ async function handleConnectionRequest(origin) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check allowed list or in-memory connected
|
// Check allowed list or a connection allowed without "Remember"
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(origin) ||
|
||||||
connectedSites[origin + ":" + activeAddress]
|
(await isConnected(origin, activeAddress))
|
||||||
) {
|
) {
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
}
|
}
|
||||||
@@ -804,7 +826,7 @@ async function handleConnectionRequest(origin) {
|
|||||||
if (decision.remember) {
|
if (decision.remember) {
|
||||||
await rememberSiteChoice("allowedSites", activeAddress, origin);
|
await rememberSiteChoice("allowedSites", activeAddress, origin);
|
||||||
} else {
|
} else {
|
||||||
connectedSites[origin + ":" + activeAddress] = true;
|
await sessionStorageSet({ [origin + ":" + activeAddress]: true });
|
||||||
}
|
}
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
} else {
|
} else {
|
||||||
@@ -858,7 +880,7 @@ async function handleRpc(method, params, origin) {
|
|||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(origin) ||
|
||||||
connectedSites[origin + ":" + activeAddress]
|
(await isConnected(origin, activeAddress))
|
||||||
) {
|
) {
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
}
|
}
|
||||||
@@ -894,7 +916,7 @@ async function handleRpc(method, params, origin) {
|
|||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(origin) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!(await isConnected(origin, activeAddress))
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
}
|
}
|
||||||
@@ -987,10 +1009,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
const isConnected =
|
const hasPermission =
|
||||||
allowed.includes(origin) ||
|
allowed.includes(origin) ||
|
||||||
connectedSites[origin + ":" + activeAddress];
|
(await isConnected(origin, activeAddress));
|
||||||
if (!isConnected || !activeAddress) {
|
if (!hasPermission || !activeAddress) {
|
||||||
return { result: [] };
|
return { result: [] };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1017,7 +1039,7 @@ async function handleRpc(method, params, origin) {
|
|||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(origin) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!(await isConnected(origin, activeAddress))
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
}
|
}
|
||||||
@@ -1071,7 +1093,7 @@ async function handleRpc(method, params, origin) {
|
|||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(origin) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!(await isConnected(origin, activeAddress))
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
}
|
}
|
||||||
@@ -1142,7 +1164,7 @@ async function handleSendTransaction(params, origin) {
|
|||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(origin) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!(await isConnected(origin, activeAddress))
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
}
|
}
|
||||||
@@ -1254,10 +1276,6 @@ async function broadcastChainChanged(chainId) {
|
|||||||
|
|
||||||
// Broadcast accountsChanged to all tabs, respecting per-address permissions
|
// Broadcast accountsChanged to all tabs, respecting per-address permissions
|
||||||
async function broadcastAccountsChanged() {
|
async function broadcastAccountsChanged() {
|
||||||
// Clear non-remembered approvals on address switch
|
|
||||||
for (const key of Object.keys(connectedSites)) {
|
|
||||||
delete connectedSites[key];
|
|
||||||
}
|
|
||||||
// Reject and close any pending approval popups so they don't hang. An
|
// Reject and close any pending approval popups so they don't hang. An
|
||||||
// approval an attempt has already claimed is left alone entirely: it is
|
// approval an attempt has already claimed is left alone entirely: it is
|
||||||
// being signed and broadcast right now, and neither rejecting it to the
|
// being signed and broadcast right now, and neither rejecting it to the
|
||||||
@@ -1276,6 +1294,10 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
resetPopupUrl();
|
resetPopupUrl();
|
||||||
|
// An address switch ends every connection allowed without "Remember". This
|
||||||
|
// waits on storage, so it comes after the pending approvals above, which
|
||||||
|
// are still rejected the moment the switch arrives.
|
||||||
|
await endConnections(() => true);
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
||||||
@@ -1290,7 +1312,7 @@ async function broadcastAccountsChanged() {
|
|||||||
const hasPermission =
|
const hasPermission =
|
||||||
activeAddress &&
|
activeAddress &&
|
||||||
(allowed.includes(origin) ||
|
(allowed.includes(origin) ||
|
||||||
connectedSites[origin + ":" + activeAddress]);
|
(await isConnected(origin, activeAddress)));
|
||||||
// Same as chainChanged above: a tab without our content script
|
// Same as chainChanged above: a tab without our content script
|
||||||
// rejects, and that is expected rather than a fault.
|
// rejects, and that is expected rather than a fault.
|
||||||
tabsSendMessage(tab.id, {
|
tabsSendMessage(tab.id, {
|
||||||
@@ -1917,41 +1939,57 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The popup removed these addresses, so no site stays connected to them.
|
// The popup removed these addresses, so no site stays connected to them.
|
||||||
// A connectedSites key is origin + ":" + address, and an origin can carry
|
|
||||||
// a port, so the address is what follows the last colon.
|
|
||||||
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
|
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
|
||||||
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
|
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
|
||||||
for (const key of Object.keys(connectedSites)) {
|
endConnections((key) => {
|
||||||
const address = key.slice(key.lastIndexOf(":") + 1);
|
const address = key.slice(key.lastIndexOf(":") + 1);
|
||||||
if (removed.some((a) => sameAddress(a, address))) {
|
return removed.some((a) => sameAddress(a, address));
|
||||||
delete connectedSites[key];
|
}).catch((err) => {
|
||||||
}
|
log.errorf(
|
||||||
}
|
"ending the removed addresses' connections failed:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings lists the sites connected without "Remember", which only this
|
// Settings lists the sites connected without "Remember", which only
|
||||||
// worker knows. The origin is what precedes the key's last colon.
|
// session storage holds.
|
||||||
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
||||||
sendResponse(
|
sessionStorageGet(null)
|
||||||
Object.keys(connectedSites).map((key) =>
|
.then((items) =>
|
||||||
key.slice(0, key.lastIndexOf(":")),
|
Object.keys(items).map((key) =>
|
||||||
),
|
key.slice(0, key.lastIndexOf(":")),
|
||||||
);
|
),
|
||||||
return false;
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
log.errorf("listing the connected sites failed:", err);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.then(sendResponse);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings removed this site (msg.origin) and has already dropped its
|
// Settings removed this site (msg.origin) and has already dropped its
|
||||||
// remembered entries. Its connections approved without "Remember" end
|
// remembered entries. Its connections approved without "Remember" end
|
||||||
// here, under every address, and its open tabs are told it has no account.
|
// here, under every address, and its open tabs are told it has no account.
|
||||||
|
// The answer goes back once they have ended, and Settings waits for it
|
||||||
|
// before it lists the connected sites again.
|
||||||
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
||||||
for (const key of Object.keys(connectedSites)) {
|
endConnections(
|
||||||
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
|
(key) => key.slice(0, key.lastIndexOf(":")) === msg.origin,
|
||||||
delete connectedSites[key];
|
)
|
||||||
}
|
.catch((err) => {
|
||||||
}
|
log.errorf(
|
||||||
broadcastSiteRemoved(msg.origin);
|
"ending the removed site's connections failed:",
|
||||||
return false;
|
err,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
broadcastSiteRemoved(msg.origin);
|
||||||
|
sendResponse(null);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings switched the network and has saved it. Open tabs are told the
|
// Settings switched the network and has saved it. Open tabs are told the
|
||||||
|
|||||||
@@ -76,11 +76,13 @@ function forgetOrigin(siteMap, origin) {
|
|||||||
|
|
||||||
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
||||||
// no longer allowed under any address, and the background ends its
|
// no longer allowed under any address, and the background ends its
|
||||||
// connections approved without "Remember" and tells its open tabs.
|
// connections approved without "Remember" and tells its open tabs. The
|
||||||
|
// background answers once those have ended, so the lists drawn after it no
|
||||||
|
// longer show the site.
|
||||||
async function removeAllowedSite(origin) {
|
async function removeAllowedSite(origin) {
|
||||||
forgetOrigin(state.allowedSites, origin);
|
forgetOrigin(state.allowedSites, origin);
|
||||||
await saveState();
|
await saveState();
|
||||||
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
||||||
await renderSiteLists();
|
await renderSiteLists();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +232,8 @@ async function renderSiteLists() {
|
|||||||
Object.values(state.deniedSites).flat(),
|
Object.values(state.deniedSites).flat(),
|
||||||
removeDeniedSite,
|
removeDeniedSite,
|
||||||
);
|
);
|
||||||
// Sites allowed without "Remember" are held only by the background, in
|
// Sites allowed without "Remember" are held only in session storage, which
|
||||||
// memory, so it is asked for them.
|
// the background keeps, so it is asked for them.
|
||||||
renderSiteList(
|
renderSiteList(
|
||||||
"settings-connected-sites",
|
"settings-connected-sites",
|
||||||
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
||||||
|
|||||||
@@ -174,9 +174,9 @@ function notify(message) {
|
|||||||
// default: resolving {} would make an existing wallet read back as no wallet,
|
// default: resolving {} would make an existing wallet read back as no wallet,
|
||||||
// and resolving a no-op write would discard the user's state with nothing
|
// and resolving a no-op write would discard the user's state with nothing
|
||||||
// logged. A caller that wants to degrade takes storageLocal() directly.
|
// logged. A caller that wants to degrade takes storageLocal() directly.
|
||||||
function storageUnavailable(method) {
|
function storageUnavailable(area, method) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new Error("extension storage.local is not available: " + method),
|
new Error("extension storage." + area + " is not available: " + method),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ function storageUnavailable(method) {
|
|||||||
*/
|
*/
|
||||||
function storageGet(keys) {
|
function storageGet(keys) {
|
||||||
const storage = storageLocal();
|
const storage = storageLocal();
|
||||||
if (!storage) return storageUnavailable("get");
|
if (!storage) return storageUnavailable("local", "get");
|
||||||
return Promise.resolve(storage.get(keys));
|
return Promise.resolve(storage.get(keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ function storageGet(keys) {
|
|||||||
*/
|
*/
|
||||||
function storageSet(items) {
|
function storageSet(items) {
|
||||||
const storage = storageLocal();
|
const storage = storageLocal();
|
||||||
if (!storage) return storageUnavailable("set");
|
if (!storage) return storageUnavailable("local", "set");
|
||||||
return Promise.resolve(storage.set(items));
|
return Promise.resolve(storage.set(items));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +215,55 @@ function storageSet(items) {
|
|||||||
*/
|
*/
|
||||||
function storageRemove(keys) {
|
function storageRemove(keys) {
|
||||||
const storage = storageLocal();
|
const storage = storageLocal();
|
||||||
if (!storage) return storageUnavailable("remove");
|
if (!storage) return storageUnavailable("local", "remove");
|
||||||
|
return Promise.resolve(storage.remove(keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `storage.session`, the browser's session storage: what is kept there lasts
|
||||||
|
// until the browser closes. It survives the background worker being stopped
|
||||||
|
// and started again, which module-level variables do not (README, Background
|
||||||
|
// scheduling). The background keeps the connections allowed without
|
||||||
|
// "Remember" there; see src/background/index.js. Firefox has it from version
|
||||||
|
// 115, the minimum manifest/firefox.json declares.
|
||||||
|
//
|
||||||
|
// The three below reject where it is absent, as the three above do where
|
||||||
|
// `storage.local` is: a write that silently did nothing would tell a site it
|
||||||
|
// is connected and then refuse its next request.
|
||||||
|
function storageSession() {
|
||||||
|
const storage = namespaceMember("storage");
|
||||||
|
return (storage && storage.session) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|string[]|null} keys null for every item.
|
||||||
|
* @returns {Promise<Object>} the stored items.
|
||||||
|
* @throws rejects where `storage.session` is absent.
|
||||||
|
*/
|
||||||
|
function sessionStorageGet(keys) {
|
||||||
|
const storage = storageSession();
|
||||||
|
if (!storage) return storageUnavailable("session", "get");
|
||||||
|
return Promise.resolve(storage.get(keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} items
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws rejects where `storage.session` is absent.
|
||||||
|
*/
|
||||||
|
function sessionStorageSet(items) {
|
||||||
|
const storage = storageSession();
|
||||||
|
if (!storage) return storageUnavailable("session", "set");
|
||||||
|
return Promise.resolve(storage.set(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|string[]} keys
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws rejects where `storage.session` is absent.
|
||||||
|
*/
|
||||||
|
function sessionStorageRemove(keys) {
|
||||||
|
const storage = storageSession();
|
||||||
|
if (!storage) return storageUnavailable("session", "remove");
|
||||||
return Promise.resolve(storage.remove(keys));
|
return Promise.resolve(storage.remove(keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +355,9 @@ module.exports = {
|
|||||||
notify,
|
notify,
|
||||||
runtimeApi,
|
runtimeApi,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sessionStorageGet,
|
||||||
|
sessionStorageRemove,
|
||||||
|
sessionStorageSet,
|
||||||
storageGet,
|
storageGet,
|
||||||
storageLocal,
|
storageLocal,
|
||||||
storageRemove,
|
storageRemove,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function sameAddress(a, b) {
|
|||||||
|
|
||||||
// Forget every site permission held against the given addresses: the
|
// Forget every site permission held against the given addresses: the
|
||||||
// remembered ones in `state`, and the connections approved without
|
// remembered ones in `state`, and the connections approved without
|
||||||
// "Remember", which only the background holds, in memory.
|
// "Remember", which only the background holds, in session storage.
|
||||||
function dropSitePermissions(state, addresses) {
|
function dropSitePermissions(state, addresses) {
|
||||||
for (const addr of addresses) {
|
for (const addr of addresses) {
|
||||||
delete state.allowedSites[addr];
|
delete state.allowedSites[addr];
|
||||||
|
|||||||
@@ -321,9 +321,10 @@ function loadBackground(options) {
|
|||||||
require("../src/background/index");
|
require("../src/background/index");
|
||||||
|
|
||||||
// Send a message the way the browser would, and hand back whatever the
|
// Send a message the way the browser would, and hand back whatever the
|
||||||
// handler passed to sendResponse.
|
// handler passed to sendResponse. `onResponse`, when given, receives the
|
||||||
function send(msg, sender) {
|
// answer when the handler sends it, as the sender's callback does.
|
||||||
const sendResponse = jest.fn();
|
function send(msg, sender, onResponse) {
|
||||||
|
const sendResponse = jest.fn(onResponse);
|
||||||
const kept = messageListener(msg, sender || {}, sendResponse);
|
const kept = messageListener(msg, sender || {}, sendResponse);
|
||||||
return { sendResponse, kept };
|
return { sendResponse, kept };
|
||||||
}
|
}
|
||||||
@@ -2615,9 +2616,9 @@ async function siteAccounts(bg, origin) {
|
|||||||
return sendResponse.mock.calls[0][0];
|
return sendResponse.mock.calls[0][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// A site connected without "Remember" is held only in the background's memory,
|
// A site connected without "Remember" is held only in the background's session
|
||||||
// keyed to the address it was connected to. Removing that address, or the
|
// storage, keyed to the address it was connected to. Removing that address, or
|
||||||
// wallet holding it, must end the connection as part of the removal itself.
|
// the wallet holding it, must end the connection as part of the removal itself.
|
||||||
// The accountsChanged broadcast the views send afterwards also clears it, but
|
// The accountsChanged broadcast the views send afterwards also clears it, but
|
||||||
// only when the active address moved, and nothing waits for it to arrive.
|
// only when the active address moved, and nothing waits for it to arrive.
|
||||||
//
|
//
|
||||||
@@ -2796,8 +2797,7 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
const element = (id) => (lists[id] ||= fakeSiteList());
|
const element = (id) => (lists[id] ||= fakeSiteList());
|
||||||
global.document = { getElementById: element };
|
global.document = { getElementById: element };
|
||||||
global.chrome.runtime.sendMessage = (msg, callback) => {
|
global.chrome.runtime.sendMessage = (msg, callback) => {
|
||||||
const { sendResponse } = bg.send(msg, bg.fromPopup);
|
bg.send(msg, bg.fromPopup, callback);
|
||||||
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
|
|
||||||
};
|
};
|
||||||
await require("../src/shared/state").loadState();
|
await require("../src/shared/state").loadState();
|
||||||
await require("../src/popup/views/settings").renderSiteLists();
|
await require("../src/popup/views/settings").renderSiteLists();
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
// A site allowed without "Remember my choice for this site" stays connected
|
||||||
|
// when the background worker is stopped and started again
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
|
||||||
|
//
|
||||||
|
// Chrome stops the worker after about 30 seconds idle and evaluates the
|
||||||
|
// module afresh on the next event, so nothing held at module scope survives
|
||||||
|
// it. Such a connection used to be held exactly there, and so lasted until the
|
||||||
|
// first idle spell. Each test here makes the connection in one load of the
|
||||||
|
// background, loads the module afresh over the same storage, as the browser
|
||||||
|
// does, and asks the new one. The paths that end a connection are driven after
|
||||||
|
// that restart, so each is shown to end what the earlier worker stored.
|
||||||
|
|
||||||
|
const { networkById } = require("../src/shared/networks");
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
const {
|
||||||
|
removeAddressFromState,
|
||||||
|
removeWalletFromState,
|
||||||
|
} = require("../src/shared/walletDelete");
|
||||||
|
|
||||||
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
const OTHER_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
|
||||||
|
|
||||||
|
const SITE = "https://dapp.example";
|
||||||
|
const OTHER_SITE = "https://other.example";
|
||||||
|
|
||||||
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
|
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
|
||||||
|
|
||||||
|
// One wallet with two addresses, so that one of them can be removed, and no
|
||||||
|
// remembered sites, so that every connection here is one made without
|
||||||
|
// "Remember".
|
||||||
|
function storedProfile() {
|
||||||
|
const mainnet = networkById("mainnet");
|
||||||
|
return {
|
||||||
|
hasWallet: true,
|
||||||
|
wallets: [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
type: "hd",
|
||||||
|
addresses: [
|
||||||
|
{ address: ADDRESS, balance: "0", tokenBalances: [] },
|
||||||
|
{ address: OTHER_ADDRESS, balance: "0", tokenBalances: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeAddress: ADDRESS,
|
||||||
|
networkId: "mainnet",
|
||||||
|
rpcUrl: mainnet.defaultRpcUrl,
|
||||||
|
blockscoutUrl: mainnet.defaultBlockscoutUrl,
|
||||||
|
allowedSites: {},
|
||||||
|
deniedSites: {},
|
||||||
|
trackedTokens: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.chrome;
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the background module afresh, as the browser does when it starts the
|
||||||
|
// worker, over `storage`. The storage stub stands in for the browser's own
|
||||||
|
// storage, local and session, so it outlives each load.
|
||||||
|
function startWorker(storage) {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.doMock("../src/shared/balances", () => ({
|
||||||
|
getProvider: () => ({}),
|
||||||
|
refreshBalances: jest.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||||
|
isPhishingDomain: () => false,
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/alarms", () => ({
|
||||||
|
BALANCE_REFRESH_ALARM: "balance",
|
||||||
|
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||||
|
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||||
|
registerAlarmHandlers: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let messageListener = null;
|
||||||
|
let connectListener = null;
|
||||||
|
// Every URL the toolbar popup was set to open: a site's connection prompt
|
||||||
|
// is shown there, so this is where its approval id appears.
|
||||||
|
const toolbarPopups = [];
|
||||||
|
const windowsCreated = [];
|
||||||
|
const sentToTabs = [];
|
||||||
|
|
||||||
|
global.chrome = {
|
||||||
|
storage,
|
||||||
|
runtime: {
|
||||||
|
getURL: (path) => EXT_URL + path,
|
||||||
|
onMessage: {
|
||||||
|
addListener: (fn) => {
|
||||||
|
messageListener = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onConnect: {
|
||||||
|
addListener: (fn) => {
|
||||||
|
connectListener = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPlatformInfo: (cb) => cb({ os: "linux" }),
|
||||||
|
// What the popup sends reaches the worker from the extension's own
|
||||||
|
// page.
|
||||||
|
sendMessage: (msg, callback) => {
|
||||||
|
messageListener(msg, FROM_POPUP, callback || (() => {}));
|
||||||
|
},
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
getLastFocused: (cb) => cb(null),
|
||||||
|
create: (options, cb) => {
|
||||||
|
windowsCreated.push(options);
|
||||||
|
cb({ id: windowsCreated.length });
|
||||||
|
},
|
||||||
|
remove: (id, cb) => {
|
||||||
|
if (cb) cb();
|
||||||
|
},
|
||||||
|
onRemoved: { addListener: () => {} },
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
|
||||||
|
sendMessage: (tabId, msg, cb) => {
|
||||||
|
sentToTabs.push(msg);
|
||||||
|
if (cb) cb();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
setPopup: (o) => {
|
||||||
|
toolbarPopups.push(o.popup);
|
||||||
|
},
|
||||||
|
openPopup: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
require("../src/background/index");
|
||||||
|
|
||||||
|
// A request from a site, answered once the worker has had time to answer.
|
||||||
|
async function rpc(method, origin, params) {
|
||||||
|
let response = null;
|
||||||
|
messageListener(
|
||||||
|
{ type: "AUTISTMASK_RPC", method, params: params || [] },
|
||||||
|
{ origin },
|
||||||
|
(r) => {
|
||||||
|
response = r;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A message from the popup, and the worker's answer to it.
|
||||||
|
async function fromPopup(msg) {
|
||||||
|
let response;
|
||||||
|
messageListener(msg, FROM_POPUP, (r) => {
|
||||||
|
response = r;
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The site asks to connect, and the user allows it in the prompt with
|
||||||
|
// "Remember my choice for this site" left unchecked.
|
||||||
|
async function connectWithoutRemember(origin) {
|
||||||
|
let response = null;
|
||||||
|
messageListener(
|
||||||
|
{ type: "AUTISTMASK_RPC", method: "eth_requestAccounts" },
|
||||||
|
{ origin },
|
||||||
|
(r) => {
|
||||||
|
response = r;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
const id = new URL(toolbarPopups.at(-1), EXT_URL).searchParams.get(
|
||||||
|
"approval",
|
||||||
|
);
|
||||||
|
const decisionListeners = [];
|
||||||
|
const port = {
|
||||||
|
name: "approval:" + id,
|
||||||
|
sender: { url: EXT_URL + "src/popup/index.html?approval=" + id },
|
||||||
|
onMessage: { addListener: (fn) => decisionListeners.push(fn) },
|
||||||
|
onDisconnect: { addListener: () => {} },
|
||||||
|
};
|
||||||
|
connectListener(port);
|
||||||
|
for (const fn of decisionListeners) {
|
||||||
|
fn(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_APPROVAL_DECISION",
|
||||||
|
approved: true,
|
||||||
|
remember: false,
|
||||||
|
},
|
||||||
|
port,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await settle();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rpc,
|
||||||
|
fromPopup,
|
||||||
|
connectWithoutRemember,
|
||||||
|
windowsCreated,
|
||||||
|
sentToTabs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A site connected without "Remember" in one worker, and the worker started
|
||||||
|
// again over the same storage, where the site is still connected.
|
||||||
|
async function connectedThenRestarted() {
|
||||||
|
const storage = makeStorageStub({ autistmask: storedProfile() });
|
||||||
|
const first = startWorker(storage);
|
||||||
|
expect(await first.connectWithoutRemember(SITE)).toEqual({
|
||||||
|
result: [ADDRESS],
|
||||||
|
});
|
||||||
|
const worker = startWorker(storage);
|
||||||
|
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
|
||||||
|
result: [ADDRESS],
|
||||||
|
});
|
||||||
|
return { storage, worker };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a site allowed without Remember, after the worker restarts", () => {
|
||||||
|
test("is still told the address, and is not asked again", async () => {
|
||||||
|
const { worker } = await connectedThenRestarted();
|
||||||
|
|
||||||
|
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
|
||||||
|
result: [ADDRESS],
|
||||||
|
});
|
||||||
|
expect(await worker.rpc("eth_requestAccounts", SITE)).toEqual({
|
||||||
|
result: [ADDRESS],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is still listed under Connected Sites in Settings", async () => {
|
||||||
|
const { worker } = await connectedThenRestarted();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
||||||
|
).toEqual([SITE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("may still ask for a signature", async () => {
|
||||||
|
// Refused with 4100 before the fix. Allowed, it raises the signing
|
||||||
|
// prompt and waits on the user.
|
||||||
|
const { worker } = await connectedThenRestarted();
|
||||||
|
|
||||||
|
const response = await worker.rpc("personal_sign", SITE, [
|
||||||
|
"0x48656c6c6f",
|
||||||
|
ADDRESS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(response).toBeNull();
|
||||||
|
expect(worker.windowsCreated).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is not connected after the browser restarts", async () => {
|
||||||
|
// The browser empties session storage when it closes; the wallet's
|
||||||
|
// own storage keeps nothing of the connection.
|
||||||
|
const { storage } = await connectedThenRestarted();
|
||||||
|
const afterBrowserRestart = startWorker(
|
||||||
|
makeStorageStub({ autistmask: storage.read("autistmask") }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await afterBrowserRestart.rpc("eth_accounts", SITE)).toEqual({
|
||||||
|
result: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("each way a connection allowed without Remember ends, after the worker restarts", () => {
|
||||||
|
test("removing the site in Settings", async () => {
|
||||||
|
const { worker } = await connectedThenRestarted();
|
||||||
|
expect(await worker.connectWithoutRemember(OTHER_SITE)).toEqual({
|
||||||
|
result: [ADDRESS],
|
||||||
|
});
|
||||||
|
|
||||||
|
await worker.fromPopup({
|
||||||
|
type: "AUTISTMASK_REMOVE_SITE",
|
||||||
|
origin: SITE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
|
||||||
|
expect(worker.sentToTabs).toEqual([
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_EVENT",
|
||||||
|
eventName: "accountsChanged",
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
||||||
|
).toEqual([OTHER_SITE]);
|
||||||
|
expect(await worker.rpc("eth_accounts", OTHER_SITE)).toEqual({
|
||||||
|
result: [ADDRESS],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching to another address", async () => {
|
||||||
|
// Switched away and back again, so the answer turns on the connection
|
||||||
|
// having ended rather than on which address is active.
|
||||||
|
const { storage, worker } = await connectedThenRestarted();
|
||||||
|
const switched = { ...storage.read("autistmask") };
|
||||||
|
|
||||||
|
switched.activeAddress = OTHER_ADDRESS;
|
||||||
|
storage.write("autistmask", switched);
|
||||||
|
await worker.fromPopup({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||||
|
switched.activeAddress = ADDRESS;
|
||||||
|
storage.write("autistmask", switched);
|
||||||
|
|
||||||
|
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
|
||||||
|
expect(
|
||||||
|
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The two below ask while storage still names the removed address as
|
||||||
|
// active, because the popup has not saved yet, so the answer turns on the
|
||||||
|
// connection alone.
|
||||||
|
test("removing the connected address", async () => {
|
||||||
|
const { storage, worker } = await connectedThenRestarted();
|
||||||
|
|
||||||
|
const popupState = storage.read("autistmask");
|
||||||
|
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing the wallet holding the connected address", async () => {
|
||||||
|
const { storage, worker } = await connectedThenRestarted();
|
||||||
|
|
||||||
|
removeWalletFromState(storage.read("autistmask"), 0);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing a different address leaves it connected", async () => {
|
||||||
|
const { storage, worker } = await connectedThenRestarted();
|
||||||
|
|
||||||
|
const popupState = storage.read("autistmask");
|
||||||
|
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
|
||||||
|
result: [ADDRESS],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-4
@@ -3861,10 +3861,8 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
|
|||||||
// all; this measures that the whole address is visible (#517).
|
// all; this measures that the whole address is visible (#517).
|
||||||
await assertAddressesFit(popup, "the connection prompt");
|
await assertAddressesFit(popup, "the connection prompt");
|
||||||
|
|
||||||
// Remembered, so the connection survives a background worker that MV3
|
// Remembered, because the sign and transaction tests below all
|
||||||
// terminates after 30 seconds idle. The in-memory connectedSites map
|
// require the origin to still be authorized.
|
||||||
// does not, and the sign and transaction tests below all require the
|
|
||||||
// origin to still be authorized.
|
|
||||||
await popup.check("#approve-remember");
|
await popup.check("#approve-remember");
|
||||||
await clickAndClose(popup, "#btn-approve");
|
await clickAndClose(popup, "#btn-approve");
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
// Before the approvals are raised it watches the worker stop on its own, so a
|
// Before the approvals are raised it watches the worker stop on its own, so a
|
||||||
// run in which something else keeps the worker running cannot pass.
|
// run in which something else keeps the worker running cannot pass.
|
||||||
//
|
//
|
||||||
|
// The site is connected without "Remember", and once the worker has stopped
|
||||||
|
// the program requires the site still to be told the address: such a
|
||||||
|
// connection used to be held in the worker's memory and ended with it
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
|
||||||
|
//
|
||||||
// The dApp page and the stub node are the loopback ones the Firefox suite also
|
// The dApp page and the stub node are the loopback ones the Firefox suite also
|
||||||
// uses (tests/e2e/dapp.js). The site is connected and the node chosen
|
// uses (tests/e2e/dapp.js). The site is connected and the node chosen
|
||||||
// through the extension's own screens, so nothing is written to its storage
|
// through the extension's own screens, so nothing is written to its storage
|
||||||
@@ -311,8 +316,8 @@ async function main() {
|
|||||||
);
|
);
|
||||||
await send("Target.closeTarget", { targetId: popup.targetId });
|
await send("Target.closeTarget", { targetId: popup.targetId });
|
||||||
|
|
||||||
// The site, connected through the real prompt with "Remember", so
|
// The site, connected through the real prompt without "Remember", so
|
||||||
// the connection is stored rather than held by the worker.
|
// the connection is one that has to outlive the worker stopping below.
|
||||||
const dapp = await openPage(node.url);
|
const dapp = await openPage(node.url);
|
||||||
await until("the injected provider", () =>
|
await until("the injected provider", () =>
|
||||||
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
|
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
|
||||||
@@ -329,7 +334,7 @@ async function main() {
|
|||||||
inPrompt(shows("view-approve-site")),
|
inPrompt(shows("view-approve-site")),
|
||||||
);
|
);
|
||||||
await inPrompt(
|
await inPrompt(
|
||||||
"document.getElementById('approve-remember').checked = true",
|
"document.getElementById('approve-remember').checked = false",
|
||||||
);
|
);
|
||||||
// The prompt closes itself on the click.
|
// The prompt closes itself on the click.
|
||||||
await inPrompt(click("btn-approve")).catch(() => {});
|
await inPrompt(click("btn-approve")).catch(() => {});
|
||||||
@@ -365,6 +370,20 @@ async function main() {
|
|||||||
"s",
|
"s",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The site's request starts the worker again, and the new worker still
|
||||||
|
// has the connection allowed without "Remember"
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
|
||||||
|
const stillConnected = await dapp.evaluate(
|
||||||
|
"window.ethereum.request({ method: 'eth_accounts' })",
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"a site allowed without Remember is still connected after the worker stopped",
|
||||||
|
Array.isArray(stillConnected) &&
|
||||||
|
stillConnected.length === 1 &&
|
||||||
|
getAddress(stillConnected[0]) === address,
|
||||||
|
"eth_accounts answered " + JSON.stringify(stillConnected),
|
||||||
|
);
|
||||||
|
|
||||||
// A transaction approval and a signature approval, both open at once.
|
// A transaction approval and a signature approval, both open at once.
|
||||||
await dapp.evaluate(
|
await dapp.evaluate(
|
||||||
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
|
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// A chrome.storage.local stub that behaves like the real one.
|
// A chrome.storage stub that behaves like the real one.
|
||||||
//
|
//
|
||||||
// The real extension storage API is a serialization boundary: `set` writes a
|
// The real extension storage API is a serialization boundary: `set` writes a
|
||||||
// structured clone of what it is given, and `get` hands back a structured
|
// structured clone of what it is given, and `get` hands back a structured
|
||||||
@@ -17,13 +17,8 @@
|
|||||||
// So every test that drives real persistence uses this, and nothing rebuilds
|
// So every test that drives real persistence uses this, and nothing rebuilds
|
||||||
// a storage stub by hand.
|
// a storage stub by hand.
|
||||||
|
|
||||||
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
|
// One storage area: get, set and remove over its own store, cloning both ways.
|
||||||
// `onOp` runs before each operation, for a test that needs to advance a clock
|
function makeArea(store, tick) {
|
||||||
// or count round trips.
|
|
||||||
function makeStorageStub(initial, onOp) {
|
|
||||||
const store = initial ? structuredClone(initial) : {};
|
|
||||||
const tick = onOp || (() => {});
|
|
||||||
|
|
||||||
const get = jest.fn(async (key) => {
|
const get = jest.fn(async (key) => {
|
||||||
tick();
|
tick();
|
||||||
if (key === undefined || key === null) return structuredClone(store);
|
if (key === undefined || key === null) return structuredClone(store);
|
||||||
@@ -49,15 +44,28 @@ function makeStorageStub(initial, onOp) {
|
|||||||
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
|
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { get, set, remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
// `initial` is the starting contents of `storage.local`, keyed as storage is:
|
||||||
|
// { autistmask: {...} }. `storage.session` starts empty.
|
||||||
|
// `onOp` runs before each operation on either area, for a test that needs to
|
||||||
|
// advance a clock or count round trips.
|
||||||
|
function makeStorageStub(initial, onOp) {
|
||||||
|
const store = initial ? structuredClone(initial) : {};
|
||||||
|
const tick = onOp || (() => {});
|
||||||
|
const local = makeArea(store, tick);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Drop this straight in as chrome.storage.
|
// Drop this straight in as chrome.storage.
|
||||||
local: { get, set, remove },
|
local,
|
||||||
get,
|
session: makeArea({}, tick),
|
||||||
set,
|
get: local.get,
|
||||||
remove,
|
set: local.set,
|
||||||
// What is stored, cloned on the way out: a test can neither observe a
|
remove: local.remove,
|
||||||
// later write through an object it read nor reach into the store by
|
// What is stored in `storage.local`, cloned on the way out: a test can
|
||||||
// mutating one.
|
// neither observe a later write through an object it read nor reach
|
||||||
|
// into the store by mutating one.
|
||||||
read: (key) =>
|
read: (key) =>
|
||||||
key === undefined
|
key === undefined
|
||||||
? structuredClone(store)
|
? structuredClone(store)
|
||||||
|
|||||||
Reference in New Issue
Block a user