mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-24 23:58:47 +00:00
fix: PUT-1355 PUT-1351 PUT-1208 (#3420)
* fix: PUT-1355 PUT-1351 PUT-1208 * fix: dev center header
This commit is contained in:
1 parent
645409eddb
commit
89f9f9728f
13 files changed
+414
-44
No files matched your search
@@ -277,6 +277,43 @@ describe('HomepageController GET /app/:name', () => {
|
||||
expect(html).toMatch(/<!DOCTYPE html>/i);
|
||||
expect(html).toContain('Cool App');
|
||||
});
|
||||
|
||||
it('does not leak a private app index_url or owner id to an anonymous visitor', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `priv-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const secretUrl = `https://secret-${name}.example.com/`;
|
||||
// `is_private` is a READ_ONLY_COLUMN, so it can't be set through the
|
||||
// store's create/update allow-list — insert the private row directly.
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`description\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
`app-${uuidv4()}`,
|
||||
name,
|
||||
'Private App',
|
||||
'a private app row',
|
||||
secretUrl,
|
||||
userId,
|
||||
1,
|
||||
],
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/app/:name',
|
||||
makeReq({ params: { name }, path: `/app/${name}` }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const html = String(captured.body);
|
||||
// Public meta (title) is fine to render...
|
||||
expect(html).toContain('Private App');
|
||||
// ...but the private hosting URL and owner id must be redacted.
|
||||
expect(html).not.toContain(secretUrl);
|
||||
expect(html).not.toContain('owner_user_id');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /show/* ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,6 +101,18 @@ export class HomepageController extends PuterController {
|
||||
? safeJsonParse(app.metadata)
|
||||
: (app.metadata as Record<string, unknown> | null)) ??
|
||||
{};
|
||||
// Never bake the raw store row into the shell — it exposes
|
||||
// `index_url`, `owner_user_id`, and admin flags. Run it
|
||||
// through the authoritative serializer, which redacts to a
|
||||
// safe field subset and drops `index_url` for a private app
|
||||
// this actor (possibly anonymous) isn't entitled to.
|
||||
const actor =
|
||||
(req as express.Request & { actor?: unknown }).actor ??
|
||||
null;
|
||||
const clientApp = await this.drivers.apps.toClientView(
|
||||
app,
|
||||
actor,
|
||||
);
|
||||
await sendShell(req, res, {
|
||||
title: String(app.title ?? name),
|
||||
description: String(app.description ?? ''),
|
||||
@@ -110,7 +122,7 @@ export class HomepageController extends PuterController {
|
||||
typeof metadata.social_image === 'string'
|
||||
? metadata.social_image
|
||||
: undefined,
|
||||
app: app as Record<string, unknown>,
|
||||
app: clientApp as Record<string, unknown>,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,6 +331,27 @@ describe('buildPrivateHostRedirect', () => {
|
||||
expect(url).toBe('http://beans.app.puter.localhost:4100/');
|
||||
});
|
||||
|
||||
it('collapses a scheme-relative path so it cannot escape the host (open redirect)', () => {
|
||||
for (const evil of [
|
||||
'//evil.com/',
|
||||
'///evil.com/',
|
||||
'/\\evil.com/',
|
||||
'/\\/evil.com/',
|
||||
]) {
|
||||
const url = buildPrivateHostRedirect(
|
||||
reqOf({
|
||||
hostname: 'beans.site.puter.localhost',
|
||||
originalUrl: evil,
|
||||
}),
|
||||
{ name: 'beans' },
|
||||
cfg,
|
||||
);
|
||||
expect(url).toBe(
|
||||
'http://beans.app.puter.localhost:4100/evil.com/',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when no private hosting domain is configured', () => {
|
||||
const noPrivate = {
|
||||
...cfg,
|
||||
@@ -400,6 +421,26 @@ describe('buildPublicHostRedirect', () => {
|
||||
expect(url).toBe('http://beans.site.puter.localhost:4100/');
|
||||
});
|
||||
|
||||
it('collapses a scheme-relative path so it cannot escape the host (open redirect)', () => {
|
||||
for (const evil of [
|
||||
'//evil.com/',
|
||||
'///evil.com/',
|
||||
'/\\evil.com/',
|
||||
'/\\/evil.com/',
|
||||
]) {
|
||||
const url = buildPublicHostRedirect(
|
||||
reqOf({
|
||||
hostname: 'beans.app.puter.localhost',
|
||||
originalUrl: evil,
|
||||
}),
|
||||
cfg,
|
||||
);
|
||||
expect(url).toBe(
|
||||
'http://beans.site.puter.localhost:4100/evil.com/',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when no public hosting domain is configured', () => {
|
||||
const noPublic = {
|
||||
...cfg,
|
||||
|
||||
@@ -584,6 +584,17 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
|
||||
// -- Redirect helpers ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Turn a request path into a safe reference for `new URL(path, base)`.
|
||||
* Collapses any run of leading slashes/backslashes into a single `/` so a
|
||||
* scheme-relative path (`//evil.com`, `/\evil.com`, which the URL parser
|
||||
* folds to `//`) can't override the base authority and turn the redirect
|
||||
* into an open redirect. Guarantees exactly one leading slash.
|
||||
*/
|
||||
function normalizeRedirectPath(originalUrl: string | undefined): string {
|
||||
return '/' + (originalUrl || '/').replace(/^[/\\]+/, '');
|
||||
}
|
||||
|
||||
/** Build the URL to redirect a private-app request to its private host. */
|
||||
export function buildPrivateHostRedirect(
|
||||
req: Request,
|
||||
@@ -605,9 +616,7 @@ export function buildPrivateHostRedirect(
|
||||
try {
|
||||
const protocol = config.protocol || req.protocol || 'https';
|
||||
const base = `${protocol}://${subdomain}.${privateDomain}`;
|
||||
const reqPath = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
: `/${req.originalUrl}`;
|
||||
const reqPath = normalizeRedirectPath(req.originalUrl);
|
||||
return new URL(reqPath, base).toString();
|
||||
} catch {
|
||||
return null;
|
||||
@@ -638,9 +647,7 @@ export function buildPublicHostRedirect(
|
||||
try {
|
||||
const protocol = config.protocol || req.protocol || 'https';
|
||||
const base = `${protocol}://${subdomain}.${publicDomain}`;
|
||||
const reqPath = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
: `/${req.originalUrl}`;
|
||||
const reqPath = normalizeRedirectPath(req.originalUrl);
|
||||
return new URL(reqPath, base).toString();
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -890,6 +890,23 @@ export class AppDriver extends PuterDriver {
|
||||
return subdomainOwnerId !== appOwnerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper over the authoritative client serializer. Callers
|
||||
* outside the driver (e.g. the homepage shell baking app metadata into
|
||||
* the rendered GUI) must never embed a raw store row — it carries
|
||||
* `owner_user_id`, admin flags, and the private `index_url`. This
|
||||
* projects the safe field subset and applies the private-app
|
||||
* `index_url` gate for `actor`, which may be null for an anonymous
|
||||
* request.
|
||||
*
|
||||
* @param {Record<string, unknown>} app - raw app store row
|
||||
* @param {any} [actor] - request actor, or null when anonymous
|
||||
* @param {Record<string, unknown>} [params]
|
||||
*/
|
||||
async toClientView(app, actor = null, params = {}) {
|
||||
return this.#toClient(app, actor, params);
|
||||
}
|
||||
|
||||
async #toClient(app, actor, params = {}) {
|
||||
if (!app) return null;
|
||||
|
||||
|
||||
@@ -476,6 +476,17 @@ describe('MeteringService', () => {
|
||||
expect(result['kv:write']).toMatchObject({ count: 1, units: 2 });
|
||||
});
|
||||
|
||||
it('returns zero and writes nothing when every item is skipped', async () => {
|
||||
const incrSpy = vi.spyOn(server.stores.kv, 'incr');
|
||||
const result = await target.batchIncrementUsages(actor, [
|
||||
{ usageType: '', usageAmount: 1, costOverride: 10 },
|
||||
{ usageType: 'kv:write', usageAmount: 0, costOverride: 20 },
|
||||
]);
|
||||
expect(result).toEqual({ total: 0 });
|
||||
expect(incrSpy).not.toHaveBeenCalled();
|
||||
incrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('raises an alarm for any negative costOverride in the batch', async () => {
|
||||
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
|
||||
await target.batchIncrementUsages(actor, [
|
||||
|
||||
@@ -351,6 +351,11 @@ export class MeteringService extends PuterService {
|
||||
(aggregated[`${escaped}.count`] || 0) + 1;
|
||||
}
|
||||
|
||||
// Every usage entry may be skipped (zero amount or missing type);
|
||||
// an empty map would build an invalid `SET ` update expression.
|
||||
if (Object.keys(aggregated).length === 0)
|
||||
return { total: 0 } as UsageByType;
|
||||
|
||||
const appId = actor.app?.uid || GLOBAL_APP_KEY;
|
||||
const userId = actor.user.uuid!;
|
||||
|
||||
|
||||
@@ -556,6 +556,12 @@ describe('SystemKVStore', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects when pathAndAmountMap is empty', async () => {
|
||||
await expect(
|
||||
target.incr({ key: 'k', pathAndAmountMap: {} }, opts),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects when any value in pathAndAmountMap is not a number', async () => {
|
||||
await expect(
|
||||
target.incr(
|
||||
|
||||
@@ -334,8 +334,7 @@ export class SystemKVStore extends PuterStore {
|
||||
res: true,
|
||||
usage: writeUsage(
|
||||
(response.ConsumedCapacity?.CapacityUnits as
|
||||
| number
|
||||
| undefined) ?? 1,
|
||||
number | undefined) ?? 1,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -366,9 +365,7 @@ export class SystemKVStore extends PuterStore {
|
||||
| { key: string; value: unknown }[]
|
||||
| {
|
||||
items:
|
||||
| string[]
|
||||
| unknown[]
|
||||
| { key: string; value: unknown }[];
|
||||
string[] | unknown[] | { key: string; value: unknown }[];
|
||||
cursor?: string;
|
||||
total?: number;
|
||||
}
|
||||
@@ -447,8 +444,7 @@ export class SystemKVStore extends PuterStore {
|
||||
usage,
|
||||
readUsage(
|
||||
(response.ConsumedCapacity?.CapacityUnits as
|
||||
| number
|
||||
| undefined) ?? 1,
|
||||
number | undefined) ?? 1,
|
||||
),
|
||||
);
|
||||
return response;
|
||||
@@ -465,8 +461,7 @@ export class SystemKVStore extends PuterStore {
|
||||
const skip = await runQuery(remaining, startKey, 'COUNT');
|
||||
remaining -= Number(skip.Count ?? 0);
|
||||
startKey = skip.LastEvaluatedKey as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
if (!startKey) {
|
||||
exhausted = remaining > 0;
|
||||
break;
|
||||
@@ -489,8 +484,7 @@ export class SystemKVStore extends PuterStore {
|
||||
>),
|
||||
);
|
||||
nextKey = response.LastEvaluatedKey as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
pages++;
|
||||
if (normalizedLimit === undefined) {
|
||||
// Legacy full listing: follow continuation pages so the
|
||||
@@ -526,8 +520,7 @@ export class SystemKVStore extends PuterStore {
|
||||
const counted = await runQuery(0, countKey, 'COUNT');
|
||||
total += Number(counted.Count ?? 0);
|
||||
countKey = counted.LastEvaluatedKey as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
} while (countKey);
|
||||
}
|
||||
|
||||
@@ -613,7 +606,7 @@ export class SystemKVStore extends PuterStore {
|
||||
KVResult<T extends { '': number } ? number : RecursiveRecord<number>>
|
||||
> {
|
||||
assertKey(key);
|
||||
if (!pathAndAmountMap)
|
||||
if (!pathAndAmountMap || Object.keys(pathAndAmountMap).length === 0)
|
||||
throw new HttpError(400, 'kv: incr requires pathAndAmountMap', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
@@ -846,8 +839,7 @@ export class SystemKVStore extends PuterStore {
|
||||
res: response.Attributes?.value,
|
||||
usage: writeUsage(
|
||||
(response.ConsumedCapacity?.CapacityUnits as
|
||||
| number
|
||||
| undefined) ?? 1,
|
||||
number | undefined) ?? 1,
|
||||
),
|
||||
};
|
||||
} catch (e) {
|
||||
|
||||
+14
-15
@@ -280,25 +280,24 @@
|
||||
<!---------------------------------------->
|
||||
<dialog class="app-users-modal" style="width: 500px; max-width: 90vw;">
|
||||
<p>Users who have authenticated into <strong class="app-users-modal-app-name"></strong></p>
|
||||
<div class="app-users-list-container" style="max-height: 400px; overflow-y: auto; margin-top: 10px;">
|
||||
<table class="table" id="app-users-table" style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p class="app-users-empty-notice" style="display:none; text-align:center; color:#888;">No users yet.</p>
|
||||
</div>
|
||||
<div class="app-users-list-container" style="max-height: 400px; overflow-y: auto; margin-top: 10px;">
|
||||
<table class="table" id="app-users-table" style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p class="app-users-empty-notice" style="display:none; text-align:center; color:#888;">No users yet.</p>
|
||||
</div>
|
||||
<div style="margin-top: 15px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="margin-top: 15px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span class="app-users-loading-notice" style="display:none; color:#888; font-size: 13px;">Loading…</span>
|
||||
<button class="button app-users-load-more-btn" style="display:none;">Load more</button>
|
||||
<span class="app-users-cancel" style="cursor:pointer; margin-left:auto;">Close</span>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!---------------------------------------->
|
||||
<!-- App List -->
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import update_username_in_gui from '../helpers/update_username_in_gui.js';
|
||||
import { openRevalidatePopup } from '../util/openid.js';
|
||||
import { fetchWithSessionCookieRetry, isSessionAuthError } from '../util/sessionAuth.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
|
||||
async function UIWindowChangeUsername (options) {
|
||||
@@ -126,8 +127,9 @@ async function UIWindowChangeUsername (options) {
|
||||
await myOpenRevalidatePopup();
|
||||
|
||||
const res = await doSubmit();
|
||||
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if ( res.ok ) onSuccess();
|
||||
else if ( isSessionAuthError(res, data) ) onReauthRequired(data);
|
||||
else onError(data.message || 'Request failed');
|
||||
return;
|
||||
}
|
||||
@@ -136,17 +138,26 @@ async function UIWindowChangeUsername (options) {
|
||||
$(el_window).find('.new-username, .change-username-password').attr('disabled', true);
|
||||
|
||||
let res = await doSubmit(password);
|
||||
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if ( res.ok ) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
if ( isSessionAuthError(res, data) ) {
|
||||
onReauthRequired(data);
|
||||
return;
|
||||
}
|
||||
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
|
||||
await myOpenRevalidatePopup(data.revalidate_url);
|
||||
const r = await doSubmit();
|
||||
if ( r.ok ) onSuccess();
|
||||
else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed'));
|
||||
if ( r.ok ) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if ( isSessionAuthError(r, d) ) onReauthRequired(d);
|
||||
else onError(d.message || 'Request failed');
|
||||
return;
|
||||
}
|
||||
onError(data.message || 'Request failed');
|
||||
@@ -156,8 +167,10 @@ async function UIWindowChangeUsername (options) {
|
||||
const new_username = $(el_window).find('.new-username').val();
|
||||
const body = { new_username };
|
||||
if ( password !== undefined && password !== '' ) body.password = password;
|
||||
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie)
|
||||
return fetch(apiUrl, {
|
||||
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie).
|
||||
// On a 401 caused by a missing/bad cookie the wrapper mints the cookie
|
||||
// from the GUI bearer token via /session/sync-cookie and retries once.
|
||||
const send = () => fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
@@ -165,6 +178,7 @@ async function UIWindowChangeUsername (options) {
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return fetchWithSessionCookieRetry(send, { origin, authToken: window.auth_token });
|
||||
}
|
||||
|
||||
function onSuccess () {
|
||||
@@ -184,6 +198,14 @@ async function UIWindowChangeUsername (options) {
|
||||
$(el_window).find('.change-username-btn').removeClass('disabled');
|
||||
$(el_window).find('.new-username, .change-username-password').attr('disabled', false);
|
||||
}
|
||||
|
||||
// Session cookie is absent and couldn't be minted from the bearer
|
||||
// token — force the sign-in flow so it gets set, then the user can
|
||||
// retry the change.
|
||||
function onReauthRequired (data) {
|
||||
onError(i18n('reauth_required_message'));
|
||||
window.handleReauthRequired({ reason: data.reason, auth_id: data.auth_id });
|
||||
}
|
||||
}
|
||||
|
||||
export default UIWindowChangeUsername;
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helpers for `/user-protected/*` endpoints, which authenticate via the
|
||||
* httpOnly session cookie only (bearer tokens are rejected so a leaked
|
||||
* token can't drive account changes). The GUI sends these requests with
|
||||
* cookies and no Authorization header, so a missing/stale cookie comes
|
||||
* back as a 401 whose `code` depends on where it was rejected:
|
||||
*
|
||||
* - `token_missing` — no cookie at all (auth gate)
|
||||
* - `token_auth_failed` — cookie present but invalid (auth gate)
|
||||
* - `reauth_required` — cookie maps to a revoked/legacy session
|
||||
* - `session_required` — authenticated via a non-cookie source
|
||||
*
|
||||
* All of these can usually be self-healed by minting a fresh cookie from
|
||||
* the GUI bearer token via `/session/sync-cookie` and retrying once. When
|
||||
* that isn't possible the caller should route through
|
||||
* `window.handleReauthRequired` so the user signs in again and the cookie
|
||||
* gets set.
|
||||
*/
|
||||
|
||||
const SESSION_AUTH_CODES = new Set([
|
||||
'token_missing',
|
||||
'token_auth_failed',
|
||||
'reauth_required',
|
||||
'session_required',
|
||||
]);
|
||||
|
||||
export const isSessionAuthError = (res, data) => {
|
||||
return res?.status === 401 && SESSION_AUTH_CODES.has(data?.code);
|
||||
};
|
||||
|
||||
export const syncSessionCookie = async ({ origin, authToken, fetchFn = globalThis.fetch }) => {
|
||||
if ( ! authToken ) return false;
|
||||
try {
|
||||
const res = await fetchFn(`${origin}/session/sync-cookie`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
return res.ok;
|
||||
} catch ( e ) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `send` (a thunk performing the user-protected fetch); on a 401
|
||||
* caused by a missing/bad session cookie, mint the cookie and retry once.
|
||||
*/
|
||||
export const fetchWithSessionCookieRetry = async (send, { origin, authToken, fetchFn } = {}) => {
|
||||
let res = await send();
|
||||
if ( res.status === 401 ) {
|
||||
const data = await res.clone().json().catch(() => ({}));
|
||||
if ( isSessionAuthError(res, data) && await syncSessionCookie({ origin, authToken, fetchFn }) ) {
|
||||
res = await send();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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 { fetchWithSessionCookieRetry, isSessionAuthError, syncSessionCookie } from './sessionAuth.js';
|
||||
|
||||
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
describe('isSessionAuthError', () => {
|
||||
it('matches every 401 shape a missing/bad session cookie produces', () => {
|
||||
for ( const code of ['token_missing', 'token_auth_failed', 'reauth_required', 'session_required'] ) {
|
||||
expect(isSessionAuthError({ status: 401 }, { code })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects other statuses, other codes, and missing data', () => {
|
||||
expect(isSessionAuthError({ status: 403 }, { code: 'session_required' })).toBe(false);
|
||||
expect(isSessionAuthError({ status: 401 }, { code: 'password_required' })).toBe(false);
|
||||
expect(isSessionAuthError({ status: 401 }, null)).toBe(false);
|
||||
expect(isSessionAuthError(null, { code: 'session_required' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncSessionCookie', () => {
|
||||
it('returns false without a bearer token and does not call fetch', async () => {
|
||||
let called = false;
|
||||
const ok = await syncSessionCookie({
|
||||
origin: 'https://puter.test',
|
||||
authToken: null,
|
||||
fetchFn: () => { called = true; },
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('calls /session/sync-cookie with the bearer token and cookies', async () => {
|
||||
let seen = null;
|
||||
const ok = await syncSessionCookie({
|
||||
origin: 'https://puter.test',
|
||||
authToken: 'tok-123',
|
||||
fetchFn: (url, opts) => {
|
||||
seen = { url, opts };
|
||||
return Promise.resolve({ ok: true });
|
||||
},
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(seen.url).toBe('https://puter.test/session/sync-cookie');
|
||||
expect(seen.opts.credentials).toBe('include');
|
||||
expect(seen.opts.headers.Authorization).toBe('Bearer tok-123');
|
||||
});
|
||||
|
||||
it('returns false on a non-ok response or a network error', async () => {
|
||||
expect(await syncSessionCookie({
|
||||
origin: 'https://puter.test',
|
||||
authToken: 'tok',
|
||||
fetchFn: () => Promise.resolve({ ok: false }),
|
||||
})).toBe(false);
|
||||
expect(await syncSessionCookie({
|
||||
origin: 'https://puter.test',
|
||||
authToken: 'tok',
|
||||
fetchFn: () => Promise.reject(new Error('offline')),
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWithSessionCookieRetry', () => {
|
||||
it('returns a successful response without syncing', async () => {
|
||||
let syncCalled = false;
|
||||
const res = await fetchWithSessionCookieRetry(
|
||||
() => Promise.resolve(jsonResponse(200, { success: true })),
|
||||
{ origin: 'https://puter.test', authToken: 'tok', fetchFn: () => { syncCalled = true; } },
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(syncCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('retries once after minting the cookie on a session-auth 401', async () => {
|
||||
// token_missing is the shape the GUI actually sees when the cookie
|
||||
// is absent (the request carries no other credential).
|
||||
for ( const code of ['token_missing', 'token_auth_failed', 'session_required'] ) {
|
||||
let sends = 0;
|
||||
let synced = false;
|
||||
const res = await fetchWithSessionCookieRetry(
|
||||
() => Promise.resolve(++sends === 1
|
||||
? jsonResponse(401, { code })
|
||||
: jsonResponse(200, { success: true })),
|
||||
{
|
||||
origin: 'https://puter.test',
|
||||
authToken: 'tok',
|
||||
fetchFn: () => {
|
||||
synced = true;
|
||||
return Promise.resolve({ ok: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(synced).toBe(true);
|
||||
expect(sends).toBe(2);
|
||||
expect(res.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the original 401 when the cookie cannot be minted', async () => {
|
||||
let sends = 0;
|
||||
const res = await fetchWithSessionCookieRetry(
|
||||
() => Promise.resolve((sends++, jsonResponse(401, { code: 'session_required' }))),
|
||||
{ origin: 'https://puter.test', authToken: null },
|
||||
);
|
||||
expect(sends).toBe(1);
|
||||
expect(res.status).toBe(401);
|
||||
// Body must still be readable by the caller after the internal peek.
|
||||
expect((await res.json()).code).toBe('session_required');
|
||||
});
|
||||
|
||||
it('does not retry non-session 401 shapes', async () => {
|
||||
let sends = 0;
|
||||
let syncCalled = false;
|
||||
const res = await fetchWithSessionCookieRetry(
|
||||
() => Promise.resolve((sends++, jsonResponse(401, { code: 'unauthorized' }))),
|
||||
{ origin: 'https://puter.test', authToken: 'tok', fetchFn: () => { syncCalled = true; } },
|
||||
);
|
||||
expect(sends).toBe(1);
|
||||
expect(syncCalled).toBe(false);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user