mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-13 00:36:02 +00:00
fix: settle a permission request from what is already granted
`puter.perms.request()` already pooled a permission read and prompted only for what was missing. The raw `puter.ui.requestPermission()` did not, so every caller still on it re-asked the user on each launch — including `perms.requestAppData()`, whose own docs promise the opposite, and the driver-denial retry. - puter.js: `ui.requestPermission()` reads what is held before prompting and resolves true when the whole request is covered. Only in env=app and env=web, the environments that raise a prompt; elsewhere the method still answers false without asking anyone. A check that cannot be made — no token, an unreadable request shape, a failed read, or one that outlasts its timeout — falls through to the prompt rather than standing in for an answer. Public signature unchanged. - GUI: the request-permission popup asks the same question as the app, using the user-app token its own exchange already mints, and skips the dialog when the access is held. This is the one case the SDK cannot settle for itself: a signed-out site holds no token to check with. An origin the browser does not vouch for never reaches the check, since the exchange fails first. Both checks are time-boxed, because each one stands in front of something that is waiting: the popup's gates the dialog, so a stalled read would leave the prompt unshown and the opener pending, and the SDK's spends the browser's transient activation, which a slow read would cost the popup. Note that driver, service and feature scopes are implicitly granted to every app (backend/data/hardcoded-permissions.js), so requests for those now settle silently — the dialog was asking about access the app already had. Consent scopes (email, fs, apps, subdomains, app-data, app-root-dir) are unaffected and still prompt until granted. Fixes a bug this method already had on the way past: `pollDecision` read an undeclared `permission`, so every attempt threw a ReferenceError into its network-failure catch and the COOP-severed-opener recovery burned its full five-minute timeout before answering false. It polls `requested` now, and requires the whole list. Tests: the e2e suite drove its dialogs with an implicitly-held driver permission, so the fixture now asks for a driver nothing implies, fresh per page load, which also removes the cross-test grant carry-over the old revokes worked around. The reconciliation tests ask for the held scope plus an unheld one, since a fully-held request no longer reaches a dialog. Adds a backend contract test for check-permissions under an app-under-user actor, which is what the two new client paths rest on.
This commit is contained in:
@@ -5024,6 +5024,64 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
]);
|
||||
});
|
||||
|
||||
// What lets a permission request settle without a prompt. The second string
|
||||
// is a consent scope nothing implies, so the first's `true` is the grant.
|
||||
it('check-permissions: an app-under-user actor sees a grant made to that app', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `cp-${uuidv4()}`,
|
||||
title: 'TestCheckPermsApp',
|
||||
index_url: 'https://check-perms.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const granted = `user:${user.uuid}:email:read`;
|
||||
const ungranted = `apps-of-user:${user.uuid}:read`;
|
||||
|
||||
const appActor = {
|
||||
user: actor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
} as unknown as Actor;
|
||||
const before = makeRes();
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [granted, ungranted] },
|
||||
{ actor: appActor },
|
||||
),
|
||||
before,
|
||||
),
|
||||
);
|
||||
expect(before.body).toEqual({
|
||||
permissions: { [granted]: false, [ungranted]: false },
|
||||
});
|
||||
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: granted, extra: {} },
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
|
||||
const after = makeRes();
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [granted, ungranted] },
|
||||
{ actor: appActor },
|
||||
),
|
||||
after,
|
||||
),
|
||||
);
|
||||
expect(after.body).toEqual({
|
||||
permissions: { [granted]: true, [ungranted]: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('list-permissions: returns the shape and includes a user→app grant with its app_uid', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The dialog waits behind this check, so a stalled read must not hold it up.
|
||||
const CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Whether every one of these permissions is already held by whoever `token`
|
||||
* identifies — an app-under-user token, so the answer is about that app's
|
||||
* access and not the user's own.
|
||||
*
|
||||
* Used to skip a permission prompt that has nothing to ask about. A check that
|
||||
* couldn't be made is not an answer: no token, an empty list, a failed read, or
|
||||
* one that times out all report `false`, leaving the prompt to run.
|
||||
*
|
||||
* @param {string[]} permissions
|
||||
* @param {string} token - App-under-user token to check as.
|
||||
* @param {object} [deps] Injectable seams for tests.
|
||||
* @param {typeof fetch} [deps.fetchImpl]
|
||||
* @param {string} [deps.apiOrigin]
|
||||
* @param {number} [deps.timeoutMs]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const holdsPermissions = async (
|
||||
permissions,
|
||||
token,
|
||||
{
|
||||
fetchImpl = globalThis.fetch?.bind(globalThis),
|
||||
apiOrigin = window.api_origin,
|
||||
timeoutMs = CHECK_TIMEOUT_MS,
|
||||
} = {},
|
||||
) => {
|
||||
if ( ! token || ! Array.isArray(permissions) || permissions.length === 0 ) {
|
||||
return false;
|
||||
}
|
||||
const controller = typeof AbortController !== 'undefined'
|
||||
? new AbortController()
|
||||
: null;
|
||||
const expiry = setTimeout(() => controller?.abort(), timeoutMs);
|
||||
try {
|
||||
const resp = await fetchImpl(`${apiOrigin}/auth/check-permissions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ permissions: [...new Set(permissions)] }),
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
});
|
||||
if ( ! resp.ok ) return false;
|
||||
const held = (await resp.json())?.permissions ?? {};
|
||||
// Every scope: one prompt is one decision, so partly-held is unheld.
|
||||
return permissions.every((p) => held[p] === true);
|
||||
} catch (e) {
|
||||
console.error('Failed to check held permissions', e);
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(expiry);
|
||||
}
|
||||
};
|
||||
|
||||
export default holdsPermissions;
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { holdsPermissions } from './holdsPermissions.js';
|
||||
|
||||
const EMAIL = 'user:u-1:email:read';
|
||||
const APPS = 'apps-of-user:u-1:read';
|
||||
|
||||
// Minimal fetch double: records calls and answers with what is held.
|
||||
const makeFetch = ({ held = {}, ok = true, throws = false } = {}) => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, opts = {}) => {
|
||||
calls.push({ url, method: opts.method, headers: opts.headers, body: JSON.parse(opts.body) });
|
||||
if ( throws ) throw new Error('network down');
|
||||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: async () => ({ permissions: held }),
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
};
|
||||
|
||||
const deps = (fetchImpl) => ({ fetchImpl, apiOrigin: 'https://api.test' });
|
||||
|
||||
describe('holdsPermissions', () => {
|
||||
it('reports held only when every permission is held', async () => {
|
||||
const both = makeFetch({ held: { [EMAIL]: true, [APPS]: true } });
|
||||
await expect(holdsPermissions([EMAIL, APPS], 'app-token', deps(both.fetchImpl)))
|
||||
.resolves.toBe(true);
|
||||
|
||||
const partial = makeFetch({ held: { [EMAIL]: true, [APPS]: false } });
|
||||
await expect(holdsPermissions([EMAIL, APPS], 'app-token', deps(partial.fetchImpl)))
|
||||
.resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('asks as the app whose access is in question, deduped', async () => {
|
||||
const { fetchImpl, calls } = makeFetch({ held: { [EMAIL]: true } });
|
||||
|
||||
await holdsPermissions([EMAIL, EMAIL], 'app-token', deps(fetchImpl));
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe('https://api.test/auth/check-permissions');
|
||||
expect(calls[0].headers.Authorization).toBe('Bearer app-token');
|
||||
expect(calls[0].body).toEqual({ permissions: [EMAIL] });
|
||||
});
|
||||
|
||||
// A check that couldn't be made is not an answer: the caller prompts.
|
||||
it('reports not held when the read fails', async () => {
|
||||
const failed = makeFetch({ ok: false });
|
||||
await expect(holdsPermissions([EMAIL], 'app-token', deps(failed.fetchImpl)))
|
||||
.resolves.toBe(false);
|
||||
|
||||
const broken = makeFetch({ throws: true });
|
||||
await expect(holdsPermissions([EMAIL], 'app-token', deps(broken.fetchImpl)))
|
||||
.resolves.toBe(false);
|
||||
});
|
||||
|
||||
// The prompt waits behind this check.
|
||||
it('gives up on a read that outlasts its timeout', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = (url, opts = {}) => {
|
||||
calls.push(opts.signal);
|
||||
return new Promise((_resolve, reject) => {
|
||||
opts.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
});
|
||||
};
|
||||
|
||||
await expect(holdsPermissions([EMAIL], 'app-token', {
|
||||
fetchImpl,
|
||||
apiOrigin: 'https://api.test',
|
||||
timeoutMs: 5,
|
||||
})).resolves.toBe(false);
|
||||
// Aborted rather than left running behind the answer.
|
||||
expect(calls[0]?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not ask at all without a token or a permission', async () => {
|
||||
const { fetchImpl, calls } = makeFetch({ held: { [EMAIL]: true } });
|
||||
|
||||
await expect(holdsPermissions([EMAIL], '', deps(fetchImpl))).resolves.toBe(false);
|
||||
await expect(holdsPermissions([], 'app-token', deps(fetchImpl))).resolves.toBe(false);
|
||||
await expect(holdsPermissions(null, 'app-token', deps(fetchImpl))).resolves.toBe(false);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
wantsFullToken,
|
||||
} from './util/authmeGrant.js';
|
||||
import init_device_signals from './helpers/deviceSignals.js';
|
||||
import { holdsPermissions } from './helpers/holdsPermissions.js';
|
||||
import item_icon from './helpers/itemIcon.js';
|
||||
import launch_app from './helpers/launchApp.js';
|
||||
import { parse_url_paths } from './helpers/urlPaths.js';
|
||||
@@ -81,6 +82,9 @@ const postAuthActions = async (action) => {
|
||||
// bootstraps the app row a permission grant is written against, so an
|
||||
// action that depends on it has to report failure rather than prompt.
|
||||
let token_exchange_failed = false;
|
||||
// The token the exchange minted for the opener's app, kept only to ask what
|
||||
// that app holds — a question this window's own token cannot answer.
|
||||
let user_app_token = null;
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: AuthMe — redirect to a third-party URL with the user's auth token
|
||||
// -------------------------------------------------------------------------------------
|
||||
@@ -385,6 +389,7 @@ const postAuthActions = async (action) => {
|
||||
// This is an implicit app and the app_uid is sent back from the server
|
||||
// we cache it here so that we can use it later
|
||||
window.host_app_uid = data.app_uid;
|
||||
user_app_token = data.token;
|
||||
// send token to parent. The opener is unreachable when it is
|
||||
// cross-origin isolated (COOP severs the relationship); those
|
||||
// flows learn the outcome server-side instead.
|
||||
@@ -688,6 +693,9 @@ const postAuthActions = async (action) => {
|
||||
if ( token_exchange_failed ) {
|
||||
throw new Error('token exchange failed; not prompting');
|
||||
}
|
||||
// A signed-out opener holds no token to settle this for itself, so
|
||||
// ask here, as the app, with the token the exchange just minted.
|
||||
const already_held = await holdsPermissions(permissions, user_app_token);
|
||||
// The requesting app is identified by its origin, and only the
|
||||
// server turns that origin into a grant target. No uid is sent
|
||||
// from here: a uid from the query string is chosen by whoever
|
||||
@@ -700,7 +708,7 @@ const postAuthActions = async (action) => {
|
||||
// name. Passing the origin instead makes the server resolve the
|
||||
// same origin the dialog displayed, and reject it outright
|
||||
// unless it names an app that really exists.
|
||||
granted = await UIPermissionDialog({
|
||||
granted = already_held || await UIPermissionDialog({
|
||||
// See IPC.js: both forms, so a single scope still works with a
|
||||
// dialog that only understands the scalar.
|
||||
permissions,
|
||||
|
||||
@@ -2,6 +2,7 @@ import EventListener from '../lib/EventListener.js';
|
||||
import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js';
|
||||
import FSItem from './FSItem.js';
|
||||
import PuterDialog from './PuterDialog.js';
|
||||
import { checkPermissions } from './perms/lib/holds.js';
|
||||
|
||||
|
||||
/**
|
||||
@@ -278,6 +279,9 @@ const pipError = (name, message) => {
|
||||
|
||||
const MAX_REQUESTED_PERMISSIONS = 16;
|
||||
|
||||
// Short on purpose: a request usually spends a user gesture while it waits.
|
||||
const PERMISSION_CHECK_TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* An interface for interacting with another app. Returned by the UI methods
|
||||
* that launch or connect to one; it cannot be constructed directly.
|
||||
@@ -1707,6 +1711,47 @@ export class UIModule extends EventListener {
|
||||
document.body.appendChild(el);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether every permission in a request is already held, read without
|
||||
* prompting and without changing anything.
|
||||
*
|
||||
* A check that couldn't be made is not an answer: no token, an unreadable
|
||||
* request, a failed read, or one that outlasts its timeout all fall through
|
||||
* to the prompt.
|
||||
*
|
||||
* @param {{ permission?: string, permissions?: string[] }} options
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async #alreadyHeld (options) {
|
||||
if ( ! this.authToken ) return false;
|
||||
const requested = Array.isArray(options?.permissions)
|
||||
? options.permissions
|
||||
: [options?.permission];
|
||||
// The prompt paths answer false for a shape they can't read themselves.
|
||||
if ( requested.length === 0
|
||||
|| requested.length > MAX_REQUESTED_PERMISSIONS
|
||||
|| requested.some(p => typeof p !== 'string' || p === '') ) {
|
||||
return false;
|
||||
}
|
||||
let expiry;
|
||||
try {
|
||||
const held = await Promise.race([
|
||||
checkPermissions(this.puter, [...new Set(requested)]),
|
||||
// Waiting out a stalled read would cost the popup its gesture.
|
||||
new Promise((resolve) => {
|
||||
expiry = setTimeout(() => resolve(null), PERMISSION_CHECK_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if ( held === null ) return false;
|
||||
// Every scope: one prompt is one decision, so partly-held is unheld.
|
||||
return requested.every(p => held[p] === true);
|
||||
} catch (e) {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(expiry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user to grant a permission to this app. Inside the Puter GUI
|
||||
* the request is relayed to the desktop; on the web the permission
|
||||
@@ -1715,10 +1760,19 @@ export class UIModule extends EventListener {
|
||||
* One prompt may cover several scopes: pass `permissions` instead of
|
||||
* `permission` and the user answers for the whole list at once.
|
||||
*
|
||||
* Access already granted resolves `true` without prompting.
|
||||
*
|
||||
* @param {{ permission?: string, permissions?: string[] }} options
|
||||
* @returns {Promise<boolean>} `true` only if the permission was granted.
|
||||
*/
|
||||
async requestPermission (options) {
|
||||
// Only where a prompt would be raised. Elsewhere this answers false
|
||||
// without asking anyone, and a check must not turn that into a grant.
|
||||
if ( ( this.env === 'app' || this.env === 'web' )
|
||||
&& await this.#alreadyHeld(options) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( this.env === 'app' ) {
|
||||
const result = await this.#postMessageAsync('requestPermission', { options });
|
||||
return result.granted === true;
|
||||
@@ -1954,12 +2008,13 @@ export class UIModule extends EventListener {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${puter.authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ permissions: [permission] }),
|
||||
body: JSON.stringify({ permissions: requested }),
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
});
|
||||
if ( ! resp.ok ) continue;
|
||||
const data = await resp.json();
|
||||
if ( data?.permissions?.[permission] === true ) {
|
||||
// The whole list, since one prompt is one decision.
|
||||
if ( requested.every(p => data?.permissions?.[p] === true) ) {
|
||||
settle(true);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The permission check goes through the shared request helper, so mocking it
|
||||
// needs no server.
|
||||
const mockReq = vi.fn();
|
||||
vi.mock('./perms/lib/req.js', () => ({ req: (...args) => mockReq(...args) }));
|
||||
|
||||
const { UIModule } = await import('./UI.js');
|
||||
|
||||
const CHECK_ROUTE = '/auth/check-permissions';
|
||||
|
||||
/** Answer the mocked helper with what is held, and nothing else. */
|
||||
const held = (permissions = {}) => {
|
||||
mockReq.mockImplementation(async (_puter, route) => {
|
||||
if ( route === CHECK_ROUTE ) return { permissions };
|
||||
throw new Error(`unexpected route: ${route}`);
|
||||
});
|
||||
};
|
||||
|
||||
const postMessage = vi.fn();
|
||||
|
||||
/** A UI module in one environment, with the GUI it posts to stubbed. */
|
||||
const makeUI = ({ env = 'app', authToken = 'token-1' } = {}) =>
|
||||
new UIModule({
|
||||
env,
|
||||
authToken,
|
||||
APIOrigin: 'https://api.test',
|
||||
appID: 'app-1',
|
||||
util: {},
|
||||
}, { appInstanceID: 'instance-1' });
|
||||
|
||||
/** The messages sent to the GUI, ignoring the constructor's READY. */
|
||||
const promptCalls = () =>
|
||||
postMessage.mock.calls.filter(([msg]) => msg?.msg === 'requestPermission');
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.window = { parent: { postMessage } };
|
||||
mockReq.mockReset();
|
||||
postMessage.mockReset();
|
||||
held();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
describe('ui.requestPermission on access that is already held', () => {
|
||||
it('resolves true without prompting the user', async () => {
|
||||
held({ 'fs:/alice/Documents:read': true });
|
||||
const ui = makeUI();
|
||||
|
||||
await expect(ui.requestPermission({
|
||||
permission: 'fs:/alice/Documents:read',
|
||||
})).resolves.toBe(true);
|
||||
|
||||
expect(promptCalls()).toHaveLength(0);
|
||||
expect(mockReq).toHaveBeenCalledWith(ui.puter, CHECK_ROUTE, {
|
||||
permissions: ['fs:/alice/Documents:read'],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves true for a whole multi-scope request', async () => {
|
||||
held({ 'apps-of-user:u-1:read': true, 'user:u-1:email:read': true });
|
||||
const ui = makeUI();
|
||||
|
||||
await expect(ui.requestPermission({
|
||||
permissions: ['apps-of-user:u-1:read', 'user:u-1:email:read'],
|
||||
})).resolves.toBe(true);
|
||||
|
||||
expect(promptCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('answers a third-party site the same way, with no popup', async () => {
|
||||
held({ 'driver:puter-image-generation:generate': true });
|
||||
const ui = makeUI({ env: 'web' });
|
||||
|
||||
await expect(ui.requestPermission({
|
||||
permission: 'driver:puter-image-generation:generate',
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.requestPermission when the check does not settle it', () => {
|
||||
// One prompt is one decision, so partly-held still has something to ask.
|
||||
it('prompts for a partly-held set', async () => {
|
||||
held({ 'apps-of-user:u-1:read': true });
|
||||
const ui = makeUI();
|
||||
|
||||
ui.requestPermission({
|
||||
permissions: ['apps-of-user:u-1:read', 'user:u-1:email:read'],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('prompts when nothing is held', async () => {
|
||||
const ui = makeUI();
|
||||
|
||||
ui.requestPermission({ permission: 'user:u-1:email:read' });
|
||||
|
||||
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1));
|
||||
});
|
||||
|
||||
// Not a grant, and not a refusal either: the prompt is where this went before.
|
||||
it('prompts when the check fails', async () => {
|
||||
mockReq.mockImplementation(async () => ({
|
||||
error: true,
|
||||
code: 'internal_error',
|
||||
}));
|
||||
const ui = makeUI();
|
||||
|
||||
ui.requestPermission({ permission: 'user:u-1:email:read' });
|
||||
|
||||
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1));
|
||||
});
|
||||
|
||||
// The request is spending a user gesture while it waits.
|
||||
it('prompts rather than waiting out a stalled check', async () => {
|
||||
mockReq.mockImplementation(() => new Promise(() => {}));
|
||||
const ui = makeUI();
|
||||
|
||||
ui.requestPermission({ permission: 'user:u-1:email:read' });
|
||||
|
||||
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1), { timeout: 10_000 });
|
||||
}, 15_000);
|
||||
|
||||
it('does not ask the server when there is no token to ask with', async () => {
|
||||
const ui = makeUI({ authToken: null });
|
||||
|
||||
ui.requestPermission({ permission: 'user:u-1:email:read' });
|
||||
|
||||
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1));
|
||||
expect(mockReq).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.requestPermission where no prompt can be raised', () => {
|
||||
// This environment has always answered false, and a check run as the user —
|
||||
// who holds far more than the app would — must not turn that into a grant.
|
||||
it('keeps answering false in the GUI, without a check', async () => {
|
||||
held({ 'user:u-1:email:read': true });
|
||||
const ui = makeUI({ env: 'gui' });
|
||||
|
||||
await expect(ui.requestPermission({
|
||||
permission: 'user:u-1:email:read',
|
||||
})).resolves.toBe(false);
|
||||
|
||||
expect(mockReq).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -289,12 +289,13 @@ export default suite('perms', {
|
||||
}
|
||||
},
|
||||
|
||||
// The one-method-per-task names still ship for apps written against them,
|
||||
// and still prompt without consulting what is already held — which is why
|
||||
// the folder and apps assertions here are the opposite of what `request`
|
||||
// now answers for the same access. `requestPermission` is the exception:
|
||||
// it forwards to `request`, so it picks up the new behaviour (asserted
|
||||
// separately below).
|
||||
// The one-method-per-task names still ship, and go straight to the prompt
|
||||
// without pooling a read of their own — which is why the folder and apps
|
||||
// assertions here are the opposite of what `request` answers. Node and
|
||||
// workerd raise no prompt, so nothing is consulted; in an app or on a
|
||||
// website `ui.requestPermission` checks first and these would answer for
|
||||
// access already held. `requestPermission` forwards to `request`, so it
|
||||
// settles from the pooled read everywhere (asserted separately below).
|
||||
'the deprecated request aliases keep delegating': {
|
||||
platforms: ['node', 'workerd'],
|
||||
fn: async (t) => {
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
</script>
|
||||
<script src="/dist/puter.dev.js"></script>
|
||||
<script>
|
||||
// A driver nothing implies: every app implicitly holds the built-in
|
||||
// driver scopes (backend hardcoded-permissions.js), and access already
|
||||
// held resolves without a prompt. Fresh per page load unless `?perm=`
|
||||
// pins it, so one test's Allow can't settle another test's request.
|
||||
window.__driverPermission =
|
||||
new URLSearchParams(location.search).get('perm')
|
||||
|| `driver:e2e-${Math.random().toString(36).slice(2, 10)}:generate`;
|
||||
|
||||
const logEl = document.getElementById('log');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
@@ -75,7 +83,7 @@
|
||||
document.getElementById('req-driver-perm').addEventListener('click', async () => {
|
||||
try {
|
||||
const granted = await puter.ui.requestPermission({
|
||||
permission: 'driver:puter-image-generation:generate',
|
||||
permission: window.__driverPermission,
|
||||
});
|
||||
log(`perm:driver:${granted}`);
|
||||
} catch (e) {
|
||||
|
||||
@@ -105,15 +105,11 @@ test.describe('puter.perms.requestAppData (env=app)', () => {
|
||||
expect(listed.res).toContain('birthday');
|
||||
expect(listed.res).not.toContain('oauthToken');
|
||||
|
||||
// -- a repeat request prompts again --
|
||||
// `requestAppData` does not consult existing grants before
|
||||
// prompting, unlike `requestEmail` (checks whoami) and the folder
|
||||
// helpers (stat first). Pinned as current behaviour: an app calling
|
||||
// this on every launch re-asks the user.
|
||||
// -- a repeat request settles from the grant, with no prompt --
|
||||
// What an app calling this on every launch relies on.
|
||||
await ask(appFrame, target.uid, { kv: ['get'] });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.locator('.perm-dialog-allow').click();
|
||||
expect(await settle(appFrame)).toEqual({ ok: true, value: true });
|
||||
await expect(dialog).toHaveCount(0);
|
||||
} finally {
|
||||
await deleteTestApp(page, appName);
|
||||
await deleteTestApp(page, target.name);
|
||||
|
||||
@@ -6,6 +6,12 @@ const PERMISSION_FIXTURE_URL = FIXTURE_URL.replace(
|
||||
'request-permission.html',
|
||||
);
|
||||
|
||||
// A driver nothing implies, for the tests that name a permission themselves:
|
||||
// the built-in driver scopes are already held, so asking for one of those would
|
||||
// settle before any dialog. Only used by requests that end in a denial, so no
|
||||
// row is left behind for the next test.
|
||||
const UNHELD_DRIVER_PERMISSION = 'driver:e2e-unheld:generate';
|
||||
|
||||
// Playwright's Chromium reports `navigator.userActivation.isActive` as true
|
||||
// even with zero interactions, which routes the SDK to the direct-popup path.
|
||||
// Stubbing it as inactive forces the consent-dialog (no-gesture) path.
|
||||
@@ -47,6 +53,28 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('access the app already holds resolves true with no dialog', async ({ page }) => {
|
||||
// Asking again on every launch is what an app has to work around.
|
||||
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
|
||||
try {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
const dialog = page.locator('dialog.perm-dialog');
|
||||
const granted = appFrame.locator('#log [data-entry="perm:email:true"]');
|
||||
|
||||
await appFrame.locator('#req-email-perm').click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.locator('.perm-dialog-allow').click();
|
||||
await expect(granted).toHaveCount(1);
|
||||
|
||||
// Asked a second time, the request settles from what is held.
|
||||
await appFrame.locator('#req-email-perm').click();
|
||||
await expect(granted).toHaveCount(2);
|
||||
await expect(dialog).toHaveCount(0);
|
||||
} finally {
|
||||
await deleteTestApp(page, appName);
|
||||
}
|
||||
});
|
||||
|
||||
test('duplicate concurrent requests share one dialog and one decision', async ({ page }) => {
|
||||
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
|
||||
try {
|
||||
@@ -136,18 +164,22 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
// desktop inert — not just the requesting app's window. Several at once
|
||||
// would wall the user in behind a pile of prompts.
|
||||
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
|
||||
// Two scopes the app lacks, named so the prompts can be told apart.
|
||||
const alpha = 'driver:e2e-alpha:generate';
|
||||
const beta = 'driver:e2e-beta:generate';
|
||||
try {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
|
||||
await appFrame.locator('body').evaluate(() => {
|
||||
window.__serialResults = Promise.all([
|
||||
puter.ui.requestPermission({ permission: 'driver:puter-image-generation:generate' }),
|
||||
puter.ui.requestPermission({ permission: 'driver:puter-chat-completion:complete' }),
|
||||
]);
|
||||
});
|
||||
await appFrame.locator('body').evaluate((_el, perms) => {
|
||||
window.__serialResults = Promise.all(
|
||||
perms.map(permission => puter.ui.requestPermission({ permission })),
|
||||
);
|
||||
}, [alpha, beta]);
|
||||
|
||||
const dialogs = page.locator('dialog.perm-dialog');
|
||||
await expect(dialogs).toHaveCount(1);
|
||||
// Each request reads what is held first, so the order is a race.
|
||||
const deniedAlpha = (await dialogs.first().innerText()).includes('e2e-alpha');
|
||||
await dialogs.first().locator('.perm-dialog-deny').click();
|
||||
// The queued request gets its own prompt once the first is answered.
|
||||
await expect(dialogs).toHaveCount(1);
|
||||
@@ -155,7 +187,7 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
|
||||
// Each caller still receives its own decision.
|
||||
const results = await appFrame.locator('body').evaluate(() => window.__serialResults);
|
||||
expect(results).toEqual([false, true]);
|
||||
expect(results).toEqual(deniedAlpha ? [false, true] : [true, false]);
|
||||
} finally {
|
||||
await deleteTestApp(page, appName);
|
||||
}
|
||||
@@ -263,9 +295,8 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
// has to undo it — otherwise the app is told "denied" while the
|
||||
// permission is live in the user's account.
|
||||
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
|
||||
const permission = 'driver:puter-image-generation:generate';
|
||||
// Scoped to this app's uid: other tests grant the same permission to
|
||||
// the fixture origin's app, whose row outlives them.
|
||||
// The fixture picks its own per page load, and this is the row to withdraw.
|
||||
let permission;
|
||||
const isGrantedTo = (appUid) => page.evaluate(async ({ perm, uid }) => {
|
||||
const res = await fetch(`${puter.APIOrigin}/auth/list-permissions`, {
|
||||
headers: { 'Authorization': `Bearer ${puter.authToken}` },
|
||||
@@ -278,6 +309,8 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
|
||||
try {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
permission = await appFrame.locator('body')
|
||||
.evaluate(() => window.__driverPermission);
|
||||
const appUid = await page.evaluate(
|
||||
async (name) => (await puter.apps.get(name)).uid,
|
||||
appName,
|
||||
@@ -302,15 +335,22 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await appFrame.locator('#req-driver-perm').click();
|
||||
// Alongside a scope the app lacks: a fully-held request never prompts.
|
||||
await appFrame.locator('body').evaluate((_el, perms) => {
|
||||
window.__reGrant = puter.ui.requestPermission({ permissions: perms });
|
||||
}, [permission, 'driver:e2e-lost-response:generate']);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.locator('.perm-dialog-allow').click();
|
||||
// The dialog hands itself back with a retryable error.
|
||||
await expect(dialog.locator('.perm-dialog-error')).toBeVisible({ timeout: 30_000 });
|
||||
await dialog.locator('.perm-dialog-deny').click();
|
||||
expect(await appFrame.locator('body').evaluate(() => window.__reGrant))
|
||||
.toBe(false);
|
||||
|
||||
await expect.poll(() => revoked, { timeout: 15_000 }).toBe(true);
|
||||
// "Don't Allow" has to mean the permission is not granted.
|
||||
// "Don't Allow" has to mean the permission is not granted. The
|
||||
// withdrawal covers every scope the prompt listed, earlier grants
|
||||
// included — the dialog's behaviour, not the check's.
|
||||
await expect.poll(() => isGrantedTo(appUid), { timeout: 15_000 }).toBe(false);
|
||||
} finally {
|
||||
await deleteTestApp(page, appName);
|
||||
@@ -327,7 +367,7 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
// permission the request may have committed is left behind, granted,
|
||||
// with the app told it was refused.
|
||||
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
|
||||
const permission = 'driver:puter-image-generation:generate';
|
||||
let permission;
|
||||
const isGrantedTo = (appUid) => page.evaluate(async ({ perm, uid }) => {
|
||||
const res = await fetch(`${puter.APIOrigin}/auth/list-permissions`, {
|
||||
headers: { 'Authorization': `Bearer ${puter.authToken}` },
|
||||
@@ -340,6 +380,8 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
|
||||
try {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
permission = await appFrame.locator('body')
|
||||
.evaluate(() => window.__driverPermission);
|
||||
const appUid = await page.evaluate(
|
||||
async (name) => (await puter.apps.get(name)).uid,
|
||||
appName,
|
||||
@@ -364,7 +406,10 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await appFrame.locator('#req-driver-perm').click();
|
||||
// Alongside a scope the app lacks, for the same reason as above.
|
||||
await appFrame.locator('body').evaluate((_el, perms) => {
|
||||
window.__reGrant = puter.ui.requestPermission({ permissions: perms });
|
||||
}, [permission, 'driver:e2e-hanging-grant:generate']);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.locator('.perm-dialog-allow').click();
|
||||
await expect(dialog.locator('.perm-dialog-allow.perm-dialog-busy')).toBeVisible();
|
||||
@@ -377,8 +422,8 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
|
||||
document.querySelector('dialog.perm-dialog')?.close();
|
||||
});
|
||||
|
||||
await expect(appFrame.locator('#log [data-entry="perm:driver:false"]'))
|
||||
.toBeVisible({ timeout: 30_000 });
|
||||
expect(await appFrame.locator('body').evaluate(() => window.__reGrant))
|
||||
.toBe(false);
|
||||
await expect.poll(() => revoked, { timeout: 20_000 }).toBe(true);
|
||||
await expect.poll(() => isGrantedTo(appUid), { timeout: 20_000 }).toBe(false);
|
||||
} finally {
|
||||
@@ -408,9 +453,9 @@ test.describe('puter.ui.requestPermission (env=gui)', () => {
|
||||
let popupOpened = false;
|
||||
page.on('popup', () => { popupOpened = true; });
|
||||
|
||||
const granted = await page.evaluate(() =>
|
||||
puter.ui.requestPermission({ permission: 'driver:puter-image-generation:generate' }),
|
||||
);
|
||||
const granted = await page.evaluate((permission) =>
|
||||
puter.ui.requestPermission({ permission }),
|
||||
UNHELD_DRIVER_PERMISSION);
|
||||
expect(granted).toBe(false);
|
||||
expect(popupOpened).toBe(false);
|
||||
// Neither the consent dialog nor the permission dialog may appear.
|
||||
@@ -499,11 +544,9 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
|
||||
|
||||
// Kick off the request from evaluate (no user activation): the SDK
|
||||
// must show the PuterDialog consent step instead of a blocked popup.
|
||||
await page.evaluate(() => {
|
||||
window.__permPromise = puter.ui.requestPermission({
|
||||
permission: 'driver:puter-image-generation:generate',
|
||||
});
|
||||
});
|
||||
await page.evaluate((permission) => {
|
||||
window.__permPromise = puter.ui.requestPermission({ permission });
|
||||
}, UNHELD_DRIVER_PERMISSION);
|
||||
const continueButton = page.locator('puter-dialog #launch-auth-popup');
|
||||
await expect(continueButton).toBeVisible();
|
||||
|
||||
@@ -527,11 +570,9 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
|
||||
await page.goto(PERMISSION_FIXTURE_URL);
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__permPromise = puter.ui.requestPermission({
|
||||
permission: 'driver:puter-image-generation:generate',
|
||||
});
|
||||
});
|
||||
await page.evaluate((permission) => {
|
||||
window.__permPromise = puter.ui.requestPermission({ permission });
|
||||
}, UNHELD_DRIVER_PERMISSION);
|
||||
const cancelButton = page.locator('puter-dialog #launch-auth-popup-cancel');
|
||||
await expect(cancelButton).toBeVisible();
|
||||
await cancelButton.click();
|
||||
@@ -550,16 +591,15 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
|
||||
await page.goto(PERMISSION_FIXTURE_URL);
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
|
||||
const outcome = await page.evaluate(async () => {
|
||||
const outcome = await page.evaluate(async (permission) => {
|
||||
window.open = () => { throw new Error('blocked by policy'); };
|
||||
const settled = puter.ui.requestPermission({
|
||||
permission: 'driver:puter-image-generation:generate',
|
||||
}).then(v => `resolved:${v}`, e => `rejected:${e?.message ?? e}`);
|
||||
const settled = puter.ui.requestPermission({ permission })
|
||||
.then(v => `resolved:${v}`, e => `rejected:${e?.message ?? e}`);
|
||||
return Promise.race([
|
||||
settled,
|
||||
new Promise(r => setTimeout(() => r('never settled'), 10_000)),
|
||||
]);
|
||||
});
|
||||
}, UNHELD_DRIVER_PERMISSION);
|
||||
expect(outcome).toBe('resolved:false');
|
||||
});
|
||||
|
||||
@@ -594,17 +634,6 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
|
||||
async (origin) => (await window.getUserAppToken(origin))?.token,
|
||||
fixtureOrigin,
|
||||
);
|
||||
await page.evaluate(async ({ origin, perm }) => {
|
||||
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
body: JSON.stringify({ origin, permission: perm }),
|
||||
});
|
||||
}, { origin: fixtureOrigin, perm: 'driver:puter-image-generation:generate' });
|
||||
|
||||
await page.goto(PERMISSION_FIXTURE_URL);
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
await page.evaluate((t) => puter.setAuthToken(t), appToken);
|
||||
@@ -633,12 +662,11 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
|
||||
await page.goto(PERMISSION_FIXTURE_URL);
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
await page.evaluate((permission) => {
|
||||
window.__permSettled = 'pending';
|
||||
window.__permPromise = puter.ui.requestPermission({
|
||||
permission: 'driver:puter-image-generation:generate',
|
||||
}).then((v) => { window.__permSettled = `resolved:${v}`; return v; });
|
||||
});
|
||||
window.__permPromise = puter.ui.requestPermission({ permission })
|
||||
.then((v) => { window.__permSettled = `resolved:${v}`; return v; });
|
||||
}, UNHELD_DRIVER_PERMISSION);
|
||||
await expect(page.locator('puter-dialog #launch-auth-popup')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
@@ -715,8 +743,6 @@ test.describe('puter.ui.requestPermission (env=web, COOP-only opener)', () => {
|
||||
// denial about a second after the click — before the user had even seen
|
||||
// the dialog — and then the Allow they went on to click committed a
|
||||
// grant the site had been told it did not get.
|
||||
const permission = 'driver:puter-image-generation:generate';
|
||||
|
||||
// Hand the site a token for its *own* app, the way a signed-in
|
||||
// third-party site holds one. Without it the poll fallback has nothing
|
||||
// to authenticate with and answers false immediately (covered above),
|
||||
@@ -735,20 +761,7 @@ test.describe('puter.ui.requestPermission (env=web, COOP-only opener)', () => {
|
||||
);
|
||||
expect(typeof appToken).toBe('string');
|
||||
|
||||
// Earlier tests grant this same permission to the fixture origin's app,
|
||||
// and the row outlives them — clear it so the poll starting out true
|
||||
// can't pass this test on its own.
|
||||
await page.evaluate(async ({ origin, perm }) => {
|
||||
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
body: JSON.stringify({ origin, permission: perm }),
|
||||
});
|
||||
}, { origin: fixtureOrigin, perm: permission });
|
||||
|
||||
// The fixture's permission is fresh per load, so no poll can start true.
|
||||
await context.route(PERMISSION_FIXTURE_URL, async (route) => {
|
||||
const resp = await route.fetch();
|
||||
await route.fulfill({
|
||||
@@ -809,8 +822,6 @@ test.describe('puter.ui.requestPermission (env=web, COOP-only opener)', () => {
|
||||
// user had decided anything. What separates the two is whether the popup
|
||||
// ever announced itself, which only reaches an opener that is still
|
||||
// attached.
|
||||
const permission = 'driver:puter-image-generation:generate';
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => !!window.getUserAppToken && !!window.auth_token,
|
||||
null, { timeout: 60_000 });
|
||||
@@ -819,19 +830,6 @@ test.describe('puter.ui.requestPermission (env=web, COOP-only opener)', () => {
|
||||
async (origin) => (await window.getUserAppToken(origin))?.token,
|
||||
fixtureOrigin,
|
||||
);
|
||||
// Earlier tests leave this permission granted to the fixture origin's
|
||||
// app; clear it so a poll that starts out true can't mask a premature
|
||||
// denial.
|
||||
await page.evaluate(async ({ origin, perm }) => {
|
||||
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
body: JSON.stringify({ origin, permission: perm }),
|
||||
});
|
||||
}, { origin: fixtureOrigin, perm: permission });
|
||||
|
||||
await context.route(PERMISSION_FIXTURE_URL, async (route) => {
|
||||
const resp = await route.fetch();
|
||||
@@ -910,7 +908,9 @@ test.describe('request-permission popup reconciliation', () => {
|
||||
// withdrawal request is fired from the closing document, so unless it
|
||||
// is sent `keepalive` the browser cancels it with the popup — leaving
|
||||
// the user told "denied" while the grant is live in their account.
|
||||
const permission = 'driver:puter-image-generation:generate';
|
||||
// Pinned, not left to the fixture's per-load default: the row must be known.
|
||||
const permission = 'driver:e2e-popup-reconcile:generate';
|
||||
const fixtureURL = `${PERMISSION_FIXTURE_URL}?perm=${encodeURIComponent(permission)}`;
|
||||
const fixtureOrigin = new URL(PERMISSION_FIXTURE_URL).origin;
|
||||
|
||||
// A GUI-origin page for server-side state checks: the fixture origin
|
||||
@@ -937,7 +937,20 @@ test.describe('request-permission popup reconciliation', () => {
|
||||
);
|
||||
}, { perm: permission, uid: appUid });
|
||||
|
||||
await page.goto(PERMISSION_FIXTURE_URL);
|
||||
// A pinned row outlives a run that dies before the withdrawal below,
|
||||
// and would settle the first request without the dialog this test needs.
|
||||
await checker.evaluate(async ({ origin, perm }) => {
|
||||
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
body: JSON.stringify({ origin, permission: perm }),
|
||||
});
|
||||
}, { origin: fixtureOrigin, perm: permission });
|
||||
|
||||
await page.goto(fixtureURL);
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
|
||||
// Grant for real first, so there is a live row the withdrawal must
|
||||
@@ -956,9 +969,12 @@ test.describe('request-permission popup reconciliation', () => {
|
||||
await context.route('**/auth/grant-user-app', route =>
|
||||
route.fulfill({ status: 502, body: '{}' }));
|
||||
|
||||
// Alongside a scope the site lacks: a fully-held request opens nothing.
|
||||
[popup] = await Promise.all([
|
||||
page.waitForEvent('popup'),
|
||||
page.locator('#req-driver-perm').click(),
|
||||
page.evaluate((perms) => {
|
||||
window.__reGrant = puter.ui.requestPermission({ permissions: perms });
|
||||
}, [permission, 'driver:e2e-popup-reconcile-2:generate']),
|
||||
]);
|
||||
const dialog = popup.locator('dialog.perm-dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 60_000 });
|
||||
@@ -967,7 +983,7 @@ test.describe('request-permission popup reconciliation', () => {
|
||||
await dialog.locator('.perm-dialog-deny').click();
|
||||
|
||||
// The popup answers and closes itself; the withdrawal must survive it.
|
||||
await expect(page.locator('#log [data-entry="perm:driver:false"]')).toBeVisible();
|
||||
expect(await page.evaluate(() => window.__reGrant)).toBe(false);
|
||||
await expect.poll(() => popup.isClosed(), { timeout: 15_000 }).toBe(true);
|
||||
await expect.poll(isGranted, { timeout: 20_000 }).toBe(false);
|
||||
});
|
||||
@@ -1040,7 +1056,7 @@ test.describe('request-permission action hardening', () => {
|
||||
// would otherwise prompt for an unnamed requester and grant to an app
|
||||
// the dialog never showed the user.
|
||||
await page.goto(
|
||||
'/action/request-permission?permission=driver%3Aputer-image-generation%3Agenerate' +
|
||||
`/action/request-permission?permission=${encodeURIComponent(UNHELD_DRIVER_PERMISSION)}` +
|
||||
'&app_uid=app-00000000-0000-4000-8000-000000000000',
|
||||
);
|
||||
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
|
||||
@@ -1123,7 +1139,7 @@ test.describe('request-permission action hardening', () => {
|
||||
window.open(
|
||||
`${puter.defaultGUIOrigin}/action/request-permission?embedded_in_popup=true`
|
||||
+ `&cross_origin_isolated=true&signin_session=${s}`
|
||||
+ '&permission=driver%3Aputer-image-generation%3Agenerate&msg_id=77',
|
||||
+ `&permission=${encodeURIComponent(UNHELD_DRIVER_PERMISSION)}&msg_id=77`,
|
||||
'perm-isolated-probe',
|
||||
'width=600,height=700',
|
||||
);
|
||||
@@ -1155,7 +1171,7 @@ test.describe('request-permission action hardening', () => {
|
||||
window.open(
|
||||
`${puter.defaultGUIOrigin}/action/request-permission?embedded_in_popup=true`
|
||||
+ `&opener_origin=${encodeURIComponent(o)}`
|
||||
+ '&permission=driver%3Aputer-image-generation%3Agenerate&msg_id=88',
|
||||
+ `&permission=${encodeURIComponent(UNHELD_DRIVER_PERMISSION)}&msg_id=88`,
|
||||
'perm-opener-origin-probe',
|
||||
'width=600,height=700',
|
||||
);
|
||||
@@ -1177,7 +1193,7 @@ test.describe('request-permission action hardening', () => {
|
||||
// handshake). Believing the query string would let a bare link raise a
|
||||
// consent prompt in any app's name and commit the user's grant to it.
|
||||
await page.goto(
|
||||
'/action/request-permission?permission=driver%3Aputer-image-generation%3Agenerate' +
|
||||
`/action/request-permission?permission=${encodeURIComponent(UNHELD_DRIVER_PERMISSION)}` +
|
||||
`&origin=${encodeURIComponent('https://not-the-requester.example/')}`,
|
||||
);
|
||||
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
|
||||
@@ -1257,3 +1273,62 @@ test.describe('request-permission action hardening', () => {
|
||||
expect(info.readsInOrder).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('request-permission popup on access already granted', () => {
|
||||
test('a signed-out site is answered without a prompt', async ({ page }) => {
|
||||
// A signed-out site holds no token to settle this itself, so the popup
|
||||
// does it once the exchange has run. Answering `true` with nothing
|
||||
// clicked is the assertion: only Allow could produce that otherwise.
|
||||
// Pinned through `?perm=`, since the grant has to name the same scope.
|
||||
const permission = 'driver:e2e-already-granted:generate';
|
||||
const fixtureURL = `${PERMISSION_FIXTURE_URL}?perm=${encodeURIComponent(permission)}`;
|
||||
const fixtureOrigin = new URL(PERMISSION_FIXTURE_URL).origin;
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => !!window.getUserAppToken && !!window.auth_token,
|
||||
null, { timeout: 60_000 });
|
||||
// Bootstraps the app row the grant is written against.
|
||||
const appToken = await page.evaluate(
|
||||
async (origin) => (await window.getUserAppToken(origin))?.token,
|
||||
fixtureOrigin,
|
||||
);
|
||||
expect(typeof appToken).toBe('string');
|
||||
|
||||
// Runs on the GUI page, which is where the user's own token lives.
|
||||
const post = (route) => page.evaluate(async ({ route: r, origin, perm }) => {
|
||||
const res = await fetch(`${window.api_origin}${r}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
body: JSON.stringify({ origin, permission: perm }),
|
||||
});
|
||||
return res.status;
|
||||
}, { route, origin: fixtureOrigin, perm: permission });
|
||||
|
||||
expect(await post('/auth/grant-user-app')).toBe(200);
|
||||
try {
|
||||
await page.goto(fixtureURL);
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
expect(await page.evaluate(() => !!puter.authToken)).toBe(false);
|
||||
|
||||
const [popup] = await Promise.all([
|
||||
page.waitForEvent('popup'),
|
||||
page.locator('#req-driver-perm').click(),
|
||||
]);
|
||||
await expect(page.locator('#log [data-entry="perm:driver:true"]'))
|
||||
.toBeVisible({ timeout: 60_000 });
|
||||
await expect.poll(() => popup.isClosed(), { timeout: 30_000 }).toBe(true);
|
||||
} finally {
|
||||
// The row outlives the test, and a stray grant is one more thing a
|
||||
// later failure could be blamed on.
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => !!window.auth_token, null, { timeout: 60_000 });
|
||||
await post('/auth/revoke-user-app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user