mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-15 02:27:35 +00:00
1164 lines
48 KiB
JavaScript
1164 lines
48 KiB
JavaScript
/*
|
|
* Copyright (C) 2026-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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { puterSiteMiddleware } from './puterSiteMiddleware';
|
|
import config from '../../config.js';
|
|
import { Context } from '../../util/context.js';
|
|
|
|
// Mocks to test middleware logic with minimal integration complexity
|
|
// (I added region markers, so this can be collapsed for readability)
|
|
|
|
// #region: mocks
|
|
let getUserMockImpl = async () => null;
|
|
let getAppMockImpl = async () => null;
|
|
|
|
vi.mock('../../config.js', () => ({
|
|
default: {
|
|
static_hosting_domain: 'site.puter.localhost',
|
|
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
|
|
private_app_hosting_domain: 'puter.dev',
|
|
private_app_hosting_domain_alt: 'puter.dev',
|
|
enable_private_app_access_gate: true,
|
|
origin: 'https://puter.com',
|
|
cookie_name: 'puter.session.token',
|
|
username_regex: /^[a-z0-9_]+$/,
|
|
},
|
|
static_hosting_domain: 'site.puter.localhost',
|
|
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
|
|
private_app_hosting_domain: 'puter.dev',
|
|
private_app_hosting_domain_alt: 'puter.dev',
|
|
enable_private_app_access_gate: true,
|
|
origin: 'https://puter.com',
|
|
cookie_name: 'puter.session.token',
|
|
username_regex: /^[a-z0-9_]+$/,
|
|
}));
|
|
|
|
vi.mock('../../modules/web/lib/api_error_handler.js', () => ({
|
|
default: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../helpers.js', () => ({
|
|
get_user: vi.fn((...args) => getUserMockImpl(...args)),
|
|
get_app: vi.fn((...args) => getAppMockImpl(...args)),
|
|
}));
|
|
|
|
vi.mock('../../util/context.js', () => ({
|
|
Context: {
|
|
get: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock Context to allow arun passthrough
|
|
const mockContextInstance = {
|
|
get: vi.fn(),
|
|
arun: vi.fn().mockImplementation(async (fn) => await fn()),
|
|
};
|
|
|
|
vi.mock('../../filesystem/node/selectors.js', () => ({
|
|
default: {
|
|
NodeInternalIDSelector: class {
|
|
},
|
|
NodePathSelector: class {
|
|
},
|
|
},
|
|
NodeInternalIDSelector: class {
|
|
},
|
|
NodePathSelector: class {
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../filesystem/FSNodeContext.js', () => ({
|
|
default: {
|
|
TYPE_DIRECTORY: 'directory',
|
|
},
|
|
TYPE_DIRECTORY: 'directory',
|
|
}));
|
|
|
|
vi.mock('../../filesystem/ll_operations/ll_read.js', () => ({
|
|
default: {
|
|
LLRead: class {
|
|
},
|
|
},
|
|
LLRead: class {
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../services/auth/Actor.js', () => ({
|
|
Actor: { adapt: vi.fn(), create: vi.fn() },
|
|
UserActorType: class {
|
|
},
|
|
SiteActorType: class {
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../api/APIError.js', () => ({
|
|
default: class APIError {
|
|
static create () {
|
|
return new this();
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../services/auth/permissionUtils.mjs', () => ({
|
|
PermissionUtil: {
|
|
reading_to_options: vi.fn().mockReturnValue([]),
|
|
},
|
|
}));
|
|
|
|
vi.mock('dedent', () => ({
|
|
default: (str) => str,
|
|
}));
|
|
// #endregion
|
|
|
|
// Now import the module under test - this will use our mocks
|
|
describe('PuterSiteMiddleware', () => {
|
|
describe('base domain redirect', () => {
|
|
let capturedMiddleware;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
config.enable_private_app_access_gate = true;
|
|
config.private_app_hosting_domain = 'puter.dev';
|
|
config.private_app_hosting_domain_alt = 'puter.dev';
|
|
Context.get = vi.fn().mockReturnValue(mockContextInstance);
|
|
getUserMockImpl = async () => null;
|
|
getAppMockImpl = async () => null;
|
|
capturedMiddleware = puterSiteMiddleware;
|
|
});
|
|
|
|
/**
|
|
* Creates a mock request for static hosting domain
|
|
*/
|
|
const createMockRequest = (subdomain) => {
|
|
const hostname = subdomain
|
|
? `${subdomain}.${config.static_hosting_domain}`
|
|
: config.static_hosting_domain;
|
|
|
|
return {
|
|
hostname,
|
|
subdomains: subdomain ? [subdomain] : [],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/',
|
|
ctx: mockContextInstance,
|
|
};
|
|
};
|
|
|
|
it('should redirect to info page when subdomain is empty (bare domain)', async () => {
|
|
const mockReq = createMockRequest('');
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should redirect to info page when subdomain is www', async () => {
|
|
const mockReq = createMockRequest('www');
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should NOT redirect when subdomain is a valid site name', async () => {
|
|
// Setup mock services for the "site not found" path
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((svc) => {
|
|
if ( svc === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue(null),
|
|
};
|
|
}
|
|
if ( svc === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockResolvedValue({
|
|
exists: vi.fn().mockResolvedValue(false),
|
|
}),
|
|
};
|
|
}
|
|
return {};
|
|
}),
|
|
};
|
|
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
|
|
const mockReq = createMockRequest('mysite');
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
// The middleware will error out further down (due to incomplete mocks)
|
|
// but the important thing is: did it try to redirect to the info page?
|
|
try {
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
} catch (e) {
|
|
// Expected - incomplete mocks cause errors after the redirect check
|
|
}
|
|
|
|
// The key assertion: it should NOT have redirected to the info page
|
|
// because 'mysite' is a valid subdomain, not '' or 'www'
|
|
expect(mockRes.redirect).not.toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
|
|
});
|
|
|
|
it('should use exactly the URL from config (not hardcoded)', async () => {
|
|
// This test verifies the middleware reads from config.static_hosting_base_domain_redirect
|
|
// If someone hardcodes a different URL, this assertion will catch that the
|
|
// redirect URL matches what is in the mocked config.
|
|
const mockReq = createMockRequest('');
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
// Verify it uses the exact URL from the mocked config
|
|
expect(mockRes.redirect).toHaveBeenCalledWith(config.static_hosting_base_domain_redirect);
|
|
});
|
|
});
|
|
|
|
describe('private app access gate', () => {
|
|
let capturedMiddleware;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
config.enable_private_app_access_gate = true;
|
|
Context.get = vi.fn().mockReturnValue(mockContextInstance);
|
|
getUserMockImpl = async () => null;
|
|
getAppMockImpl = async () => null;
|
|
capturedMiddleware = puterSiteMiddleware;
|
|
});
|
|
|
|
it('redirects private app assets to puter.dev host even before index_url migration', async () => {
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: 202,
|
|
root_dir_id: null,
|
|
}),
|
|
};
|
|
}
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.site.puter.localhost/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.site.puter.localhost',
|
|
subdomains: ['paid'],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/asset.js',
|
|
originalUrl: '/asset.js?foo=1',
|
|
query: {},
|
|
cookies: {},
|
|
headers: {},
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockRes.redirect).toHaveBeenCalledWith('https://paid.puter.dev/asset.js?foo=1');
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts private app host matching the configured alt private domain', async () => {
|
|
config.private_app_hosting_domain = 'app.puter.localhost:4100';
|
|
config.private_app_hosting_domain_alt = 'puter.dev';
|
|
|
|
const authService = {
|
|
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
|
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
authenticate_from_token: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
|
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
|
|
};
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: 202,
|
|
root_dir_id: 303,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockResolvedValue({
|
|
exists: vi.fn().mockResolvedValue(true),
|
|
get: vi.fn().mockImplementation(async (fieldName) => {
|
|
if ( fieldName === 'type' ) return 'directory';
|
|
if ( fieldName === 'path' ) return '/alice/Public';
|
|
return null;
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'acl' ) {
|
|
return {
|
|
check: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
if ( serviceName === 'auth' ) return authService;
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.puter.dev/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.puter.dev',
|
|
subdomains: ['paid'],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/index.html',
|
|
originalUrl: '/index.html',
|
|
query: {},
|
|
cookies: {},
|
|
headers: {},
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
set: vi.fn().mockReturnThis(),
|
|
setHeader: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockRes.redirect).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('app.puter.localhost:4100'),
|
|
);
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('Sign in required'));
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('serves login bootstrap html when private app identity is missing', async () => {
|
|
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
|
event.result.allowed = false;
|
|
event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
|
|
});
|
|
const dbRead = vi.fn().mockResolvedValue([
|
|
{
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.puter.dev/',
|
|
owner_user_id: 101,
|
|
},
|
|
]);
|
|
const authService = {
|
|
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
|
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
authenticate_from_token: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
|
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
|
|
};
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: null,
|
|
root_dir_id: 303,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockResolvedValue({
|
|
exists: vi.fn().mockResolvedValue(true),
|
|
get: vi.fn().mockImplementation(async (fieldName) => {
|
|
if ( fieldName === 'type' ) return 'directory';
|
|
if ( fieldName === 'path' ) return '/alice/Public';
|
|
return null;
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'acl' ) {
|
|
return {
|
|
check: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
if ( serviceName === 'database' ) {
|
|
return {
|
|
get: vi.fn().mockReturnValue({
|
|
read: dbRead,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'event' ) return { emit: eventEmit };
|
|
if ( serviceName === 'auth' ) return authService;
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.puter.dev/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.puter.dev',
|
|
subdomains: [],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/index.html',
|
|
originalUrl: '/index.html',
|
|
cookies: {},
|
|
headers: {},
|
|
query: {},
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
cookie: vi.fn(),
|
|
set: vi.fn().mockReturnThis(),
|
|
setHeader: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(eventEmit).not.toHaveBeenCalled();
|
|
expect(dbRead).toHaveBeenCalledWith(
|
|
expect.stringContaining('index_url IN'),
|
|
expect.arrayContaining([
|
|
101,
|
|
'https://paid.puter.dev',
|
|
'https://paid.puter.dev/',
|
|
'https://paid.puter.dev/index.html',
|
|
'https://paid.site.puter.localhost',
|
|
'https://paid.site.puter.localhost/',
|
|
'https://paid.site.puter.localhost/index.html',
|
|
]),
|
|
);
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('https://js.puter.com/v2/'));
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()'));
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('localStorage.getItem(\'auth_token\')'));
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('tryStoredTokenBootstrap'));
|
|
expect(mockRes.redirect).not.toHaveBeenCalled();
|
|
expect(mockRes.cookie).not.toHaveBeenCalled();
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not redirect private root requests to puter.com app route before access bootstrap', async () => {
|
|
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
|
event.result.allowed = false;
|
|
event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
|
|
});
|
|
const authService = {
|
|
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
|
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
authenticate_from_token: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
|
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
|
|
};
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: 202,
|
|
root_dir_id: 303,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockResolvedValue({
|
|
exists: vi.fn().mockResolvedValue(true),
|
|
get: vi.fn().mockImplementation(async (fieldName) => {
|
|
if ( fieldName === 'type' ) return 'directory';
|
|
if ( fieldName === 'path' ) return '/alice/Public';
|
|
return null;
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'acl' ) {
|
|
return {
|
|
check: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
if ( serviceName === 'event' ) return { emit: eventEmit };
|
|
if ( serviceName === 'auth' ) return authService;
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.site.puter.localhost/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.puter.dev',
|
|
subdomains: [],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/',
|
|
originalUrl: '/?puter.auth.token=abc',
|
|
cookies: {},
|
|
headers: {},
|
|
query: {
|
|
'puter.auth.token': 'abc',
|
|
},
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
cookie: vi.fn(),
|
|
set: vi.fn().mockReturnThis(),
|
|
setHeader: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockRes.redirect).not.toHaveBeenCalledWith('https://puter.com/app/paid-app/');
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('https://js.puter.com/v2/'));
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()'));
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('meta property="og:title"'));
|
|
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('/app/paid-app/'));
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('denies private app access and redirects using entitlement response', async () => {
|
|
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
|
event.result.allowed = false;
|
|
event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
|
|
});
|
|
const authService = {
|
|
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
|
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
authenticate_from_token: vi.fn().mockResolvedValue({
|
|
type: {},
|
|
get_related_actor: vi.fn().mockReturnValue({
|
|
type: {
|
|
user: { uuid: 'user-111' },
|
|
session: 'session-111',
|
|
},
|
|
}),
|
|
}),
|
|
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
|
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
|
|
};
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: 202,
|
|
root_dir_id: 303,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockResolvedValue({
|
|
exists: vi.fn().mockResolvedValue(true),
|
|
get: vi.fn().mockImplementation(async (fieldName) => {
|
|
if ( fieldName === 'type' ) return 'directory';
|
|
if ( fieldName === 'path' ) return '/alice/Public';
|
|
return null;
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'acl' ) {
|
|
return {
|
|
check: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
if ( serviceName === 'event' ) return { emit: eventEmit };
|
|
if ( serviceName === 'auth' ) return authService;
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.puter.dev/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.puter.dev',
|
|
subdomains: [],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/index.html',
|
|
originalUrl: '/index.html',
|
|
cookies: {
|
|
'puter.session.token': 'session-token',
|
|
},
|
|
headers: {},
|
|
query: {},
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
cookie: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(eventEmit).toHaveBeenCalledWith(
|
|
'app.privateAccess.check',
|
|
expect.objectContaining({
|
|
appUid: 'app-11111111-1111-1111-1111-111111111111',
|
|
userUid: 'user-111',
|
|
}),
|
|
);
|
|
expect(mockRes.redirect).toHaveBeenCalledWith('https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111');
|
|
expect(mockRes.cookie).not.toHaveBeenCalled();
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses bootstrap fallback identity when strict bootstrap auth fails', async () => {
|
|
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
|
event.result.allowed = false;
|
|
event.result.redirectUrl = 'https://apps.puter.com/app/paid-app';
|
|
});
|
|
const authService = {
|
|
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
|
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
authenticate_from_token: vi.fn().mockImplementation(() => {
|
|
throw new Error('token_auth_failed');
|
|
}),
|
|
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
|
|
userUid: 'user-111',
|
|
sessionUuid: 'session-111',
|
|
}),
|
|
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
|
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
|
|
};
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: 202,
|
|
root_dir_id: 303,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockResolvedValue({
|
|
exists: vi.fn().mockResolvedValue(true),
|
|
get: vi.fn().mockImplementation(async (fieldName) => {
|
|
if ( fieldName === 'type' ) return 'directory';
|
|
if ( fieldName === 'path' ) return '/alice/Public';
|
|
return null;
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'acl' ) {
|
|
return {
|
|
check: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
if ( serviceName === 'event' ) return { emit: eventEmit };
|
|
if ( serviceName === 'auth' ) return authService;
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.puter.dev/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.puter.dev',
|
|
subdomains: [],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/index.html',
|
|
originalUrl: '/index.html?puter.auth.token=bootstrap-token',
|
|
cookies: {},
|
|
headers: {},
|
|
query: {
|
|
'puter.auth.token': 'bootstrap-token',
|
|
},
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
cookie: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
|
|
expect(authService.resolvePrivateBootstrapIdentityFromToken)
|
|
.toHaveBeenCalledWith('bootstrap-token');
|
|
expect(eventEmit).toHaveBeenCalledWith(
|
|
'app.privateAccess.check',
|
|
expect.objectContaining({
|
|
appUid: 'app-11111111-1111-1111-1111-111111111111',
|
|
userUid: 'user-111',
|
|
}),
|
|
);
|
|
expect(mockRes.redirect).toHaveBeenCalledWith('https://apps.puter.com/app/paid-app');
|
|
expect(mockRes.send).not.toHaveBeenCalled();
|
|
expect(mockRes.cookie).not.toHaveBeenCalled();
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes request hostname to private asset cookie options on allow', async () => {
|
|
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
|
event.result.allowed = true;
|
|
});
|
|
const rootDirectoryNode = {
|
|
fetchEntry: vi.fn().mockResolvedValue(undefined),
|
|
exists: vi.fn().mockResolvedValue(true),
|
|
get: vi.fn().mockImplementation(async (fieldName) => {
|
|
if ( fieldName === 'type' ) return 'directory';
|
|
if ( fieldName === 'path' ) return '/alice/Public';
|
|
return null;
|
|
}),
|
|
};
|
|
const missingFileNode = {
|
|
fetchEntry: vi.fn().mockResolvedValue(undefined),
|
|
exists: vi.fn().mockResolvedValue(false),
|
|
get: vi.fn().mockResolvedValue(null),
|
|
};
|
|
let filesystemNodeCallCount = 0;
|
|
const authService = {
|
|
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
|
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
authenticate_from_token: vi.fn().mockResolvedValue({
|
|
type: {},
|
|
get_related_actor: vi.fn().mockReturnValue({
|
|
type: {
|
|
user: { uuid: 'user-allow-111' },
|
|
session: 'session-allow-111',
|
|
},
|
|
}),
|
|
}),
|
|
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
|
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
|
|
};
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: 202,
|
|
root_dir_id: 303,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockImplementation(async () => {
|
|
filesystemNodeCallCount += 1;
|
|
return filesystemNodeCallCount === 1
|
|
? rootDirectoryNode
|
|
: missingFileNode;
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'acl' ) {
|
|
return {
|
|
check: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
if ( serviceName === 'event' ) return { emit: eventEmit };
|
|
if ( serviceName === 'auth' ) return authService;
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.puter.dev/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.puter.dev',
|
|
subdomains: [],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/asset.js',
|
|
originalUrl: '/asset.js',
|
|
cookies: {
|
|
'puter.session.token': 'session-token',
|
|
},
|
|
headers: {},
|
|
query: {},
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
cookie: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
set: vi.fn().mockReturnThis(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
write: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(authService.getPrivateAssetCookieOptions).toHaveBeenCalledWith({
|
|
requestHostname: 'paid.puter.dev',
|
|
});
|
|
expect(authService.createPrivateAssetToken).toHaveBeenCalledWith({
|
|
appUid: 'app-11111111-1111-1111-1111-111111111111',
|
|
userUid: 'user-allow-111',
|
|
sessionUuid: 'session-allow-111',
|
|
subdomain: 'paid',
|
|
});
|
|
expect(mockRes.cookie).toHaveBeenCalledWith(
|
|
'puter.private.asset.token',
|
|
'private-token',
|
|
{ sameSite: 'none' },
|
|
);
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts nested query token key for bootstrap auth', async () => {
|
|
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
|
event.result.allowed = false;
|
|
event.result.redirectUrl = 'https://apps.puter.com/app/paid-app';
|
|
});
|
|
const authService = {
|
|
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
|
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
|
throw new Error('invalid');
|
|
}),
|
|
authenticate_from_token: vi.fn().mockImplementation(() => {
|
|
throw new Error('token_auth_failed');
|
|
}),
|
|
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
|
|
userUid: 'user-111',
|
|
sessionUuid: 'session-111',
|
|
}),
|
|
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
|
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
|
|
};
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: 202,
|
|
root_dir_id: 303,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockResolvedValue({
|
|
exists: vi.fn().mockResolvedValue(true),
|
|
get: vi.fn().mockImplementation(async (fieldName) => {
|
|
if ( fieldName === 'type' ) return 'directory';
|
|
if ( fieldName === 'path' ) return '/alice/Public';
|
|
return null;
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'acl' ) {
|
|
return {
|
|
check: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
if ( serviceName === 'event' ) return { emit: eventEmit };
|
|
if ( serviceName === 'auth' ) return authService;
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.puter.dev/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.puter.dev',
|
|
subdomains: [],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/index.html',
|
|
originalUrl: '/index.html?puter.auth.token=bootstrap-token',
|
|
cookies: {},
|
|
headers: {},
|
|
query: {
|
|
puter: {
|
|
auth: {
|
|
token: 'bootstrap-token',
|
|
},
|
|
},
|
|
},
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
cookie: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
|
|
expect(authService.resolvePrivateBootstrapIdentityFromToken)
|
|
.toHaveBeenCalledWith('bootstrap-token');
|
|
expect(mockRes.redirect).toHaveBeenCalledWith('https://apps.puter.com/app/paid-app');
|
|
expect(mockRes.send).not.toHaveBeenCalled();
|
|
expect(mockRes.cookie).not.toHaveBeenCalled();
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips private app gate when feature flag is disabled', async () => {
|
|
config.enable_private_app_access_gate = false;
|
|
|
|
const eventEmit = vi.fn();
|
|
const rootDirectoryNode = {
|
|
fetchEntry: vi.fn().mockResolvedValue(undefined),
|
|
exists: vi.fn().mockResolvedValue(true),
|
|
get: vi.fn().mockImplementation(async (fieldName) => {
|
|
if ( fieldName === 'type' ) return 'directory';
|
|
if ( fieldName === 'path' ) return '/alice/Public';
|
|
return null;
|
|
}),
|
|
};
|
|
const missingFileNode = {
|
|
fetchEntry: vi.fn().mockResolvedValue(undefined),
|
|
exists: vi.fn().mockResolvedValue(false),
|
|
get: vi.fn().mockResolvedValue(null),
|
|
};
|
|
|
|
let filesystemNodeCallCount = 0;
|
|
const mockServices = {
|
|
get: vi.fn().mockImplementation((serviceName) => {
|
|
if ( serviceName === 'puter-site' ) {
|
|
return {
|
|
get_subdomain: vi.fn().mockResolvedValue({
|
|
user_id: 101,
|
|
associated_app_id: 202,
|
|
root_dir_id: 303,
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'filesystem' ) {
|
|
return {
|
|
node: vi.fn().mockImplementation(async () => {
|
|
filesystemNodeCallCount += 1;
|
|
return filesystemNodeCallCount === 1
|
|
? rootDirectoryNode
|
|
: missingFileNode;
|
|
}),
|
|
};
|
|
}
|
|
if ( serviceName === 'acl' ) {
|
|
return {
|
|
check: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
if ( serviceName === 'event' ) return { emit: eventEmit };
|
|
return {};
|
|
}),
|
|
};
|
|
mockContextInstance.get.mockImplementation((key) => {
|
|
if ( key === 'services' ) return mockServices;
|
|
return null;
|
|
});
|
|
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
|
getAppMockImpl = async () => ({
|
|
uid: 'app-11111111-1111-1111-1111-111111111111',
|
|
name: 'paid-app',
|
|
is_private: 1,
|
|
index_url: 'https://paid.puter.dev/',
|
|
});
|
|
|
|
const mockReq = {
|
|
hostname: 'paid.site.puter.localhost',
|
|
subdomains: ['paid'],
|
|
is_custom_domain: false,
|
|
baseUrl: '',
|
|
path: '/asset.js',
|
|
originalUrl: '/asset.js',
|
|
query: {},
|
|
cookies: {},
|
|
headers: {},
|
|
on: vi.fn(),
|
|
ctx: mockContextInstance,
|
|
};
|
|
const mockRes = {
|
|
redirect: vi.fn(),
|
|
cookie: vi.fn(),
|
|
setHeader: vi.fn(),
|
|
set: vi.fn().mockReturnThis(),
|
|
status: vi.fn().mockReturnThis(),
|
|
send: vi.fn(),
|
|
write: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
const mockNext = vi.fn();
|
|
|
|
await capturedMiddleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockRes.redirect).not.toHaveBeenCalled();
|
|
expect(eventEmit).not.toHaveBeenCalled();
|
|
expect(mockRes.status).toHaveBeenCalledWith(404);
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|