Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/e2e/scripts/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ const ALWAYS = [
file: 'src/tests/workspaces.test.ts src/tests/workspaces-sharing-limits.test.ts',
label: 'Workspaces: create, switch, settings, sharing limits',
},
{
key: 'demo-role',
file: 'src/tests/demo-role.test.ts',
label: 'Published demo: Demo role and demo mode',
},
{
key: 'sql-editor',
file: [
Expand Down
20 changes: 20 additions & 0 deletions apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,14 @@ export const TestIds = {
adminSignInSettings: 'admin-sign-in-settings',
/** button · Copy */
signInCopy: 'sign-in-copy',
/** section · Demo mode */
signInDemo: 'sign-in-demo',
/** textarea · Demo login: demo@example.com / its password */
signInDemoHint: 'sign-in-demo-hint',
/** button · Save hint */
signInDemoSave: 'sign-in-demo-save',
/** input */
signInDemoToggle: 'sign-in-demo-toggle',
/** section · Email for invites and password resets */
signInMail: 'sign-in-mail',
/** input · Fox <fox@example.com> */
Expand Down Expand Up @@ -744,6 +752,10 @@ export const TestIds = {
/** button */
commandPaletteItem: (id: string | number) => `command-palette-item-${id}`,
},
DemoBanner: {
/** div */
demoBanner: 'demo-banner',
},
featureRegistry: {
/** data · Snapshots */
syncPaneHistoryBtn: 'sync-pane-history-btn',
Expand Down Expand Up @@ -895,6 +907,8 @@ export const TestIds = {
accountVerifySubmit: 'account-verify-submit',
},
AuthPage: {
/** div */
authDemoHint: 'auth-demo-hint',
/** button · Forgot password? */
authForgotLink: 'auth-forgot-link',
/** button · Have an invite or reset code? */
Expand Down Expand Up @@ -3769,6 +3783,7 @@ export type TestId =
| 'auth-code-back'
| 'auth-code-continue'
| 'auth-code-form'
| 'auth-demo-hint'
| 'auth-forgot-back'
| 'auth-forgot-email'
| 'auth-forgot-form'
Expand Down Expand Up @@ -4160,6 +4175,7 @@ export type TestId =
| 'db-access-status'
| `db-access-toggle-group-${string}`
| 'db-access-unsupported'
| 'demo-banner'
| 'deploy-cancel-btn'
| 'deploy-confirm-btn'
| 'deploy-confirm-dialog'
Expand Down Expand Up @@ -4606,6 +4622,10 @@ export type TestId =
| 'settings-view'
| 'settings-workspace'
| 'sign-in-copy'
| 'sign-in-demo'
| 'sign-in-demo-hint'
| 'sign-in-demo-save'
| 'sign-in-demo-toggle'
| 'sign-in-mail'
| 'sign-in-mail-from'
| 'sign-in-mail-host'
Expand Down
10 changes: 8 additions & 2 deletions apps/e2e/src/helpers/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@ export interface ApiResult {
}

/** One request to `/api<path>` as the session in `cookie`. A body that is not JSON reads as null. */
export async function apiCall(method: string, path: string, body: unknown, cookie: string): Promise<ApiResult> {
export async function apiCall(
method: string,
path: string,
body: unknown,
cookie: string,
headers: Record<string, string> = {}
): Promise<ApiResult> {
const res = await fetch(`${API_URL}/api${path}`, {
method,
headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), cookie },
headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), cookie, ...headers },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { status: res.status, json: await res.json().catch(() => null) };
Expand Down
152 changes: 152 additions & 0 deletions apps/e2e/src/tests/demo-role.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* A published demo, in a real browser against the running server.
*
* One shared login with the Demo role: a visitor reads the login off the
* sign-in page, sees the demo banner, and queries the sample database an
* admin put in its workspace. Saving anything is refused, by the server.
*
* Runs as the suites' admin plus one throwaway demo account, and turns demo
* mode off again at the end, since every other suite shares this install.
*
* Requires the web app + API (`npm run dev`) and a `sqlite3` CLI on PATH.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, execSync } from 'node:child_process';
import { mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import type { Page } from 'playwright';
import { buildDriver, quitDriver, clickWhen, waitFor, BASE_URL } from '../helpers/driver.js';
import { sessionCookie } from '../helpers/app-session.js';
import { apiCall, signInAs } from '../helpers/accounts.js';
import { saveScreenshot } from '../helpers/screenshot.js';
import { byTestId } from '../helpers/test-ids.js';
import { SqlEditorPage } from '../pages/SqlEditorPage.js';

const RUN = Date.now().toString(36);
// Per-run directory: the backend pools SQLite handles by file path.
const DIR = `/tmp/foxschema-e2e-demo-${RUN}`;
const DB = join(DIR, 'sample.db');
const CONNECTION = `Demo sample ${RUN}`;
const DEMO_EMAIL = `e2e-demo-${RUN}@foxschema.test`;
const DEMO_PASSWORD = `try-fox-${randomBytes(6).toString('hex')}`;
const HINT = `Demo login: ${DEMO_EMAIL} / ${DEMO_PASSWORD}`;

let admin = '';
let adminId = '';
let demoId = '';
let demoWorkspace = '';
let demoCookie = '';
const pages: Page[] = [];

function hasSqlite3(): boolean {
try {
execSync('which sqlite3', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}

const api = (method: string, path: string, body?: unknown, cookie = admin, headers: Record<string, string> = {}) =>
apiCall(method, path, body, cookie, headers);

describe.skipIf(!hasSqlite3())('a published demo', () => {
beforeAll(async () => {
rmSync(DIR, { recursive: true, force: true });
mkdirSync(DIR, { recursive: true });
execFileSync('sqlite3', [DB], {
input: `CREATE TABLE planets (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO planets VALUES (1, 'Mercury'), (2, 'Venus'), (3, 'Earth');`,
});

admin = await sessionCookie();
adminId = (await api('GET', '/auth/me')).json.user.id;
const made = await api('POST', '/admin/users', { email: DEMO_EMAIL, password: DEMO_PASSWORD, role: 'demo' });
expect(made.status, JSON.stringify(made.json)).toBe(200);
demoId = made.json.user.id;

// The demo cannot add connections: an admin joins its workspace, adds the
// sample database, and leaves — the path docs/DEPLOYMENT.md describes.
demoCookie = await signInAs(DEMO_EMAIL, DEMO_PASSWORD);
demoWorkspace = (await api('GET', '/workspaces', undefined, demoCookie)).json.currentId;
expect((await api('PUT', `/workspaces/${demoWorkspace}/members/${adminId}`, { role: 'owner' })).status).toBe(200);
const saved = await api(
'POST',
'/connections',
{ name: CONNECTION, dialect: 'sqlite', option: { database: DB } },
admin,
{ 'x-fox-workspace': demoWorkspace }
);
expect(saved.status, JSON.stringify(saved.json)).toBe(200);
expect((await api('DELETE', `/workspaces/${demoWorkspace}/members/${adminId}`, {})).status).toBe(200);

expect((await api('PUT', '/admin/sign-in/demo', { on: true, hint: HINT })).status).toBe(200);
}, 120_000);

afterAll(async () => {
for (const page of pages) await quitDriver(page);
await api('PUT', '/admin/sign-in/demo', { on: false, hint: '' });
if (demoId) await api('PUT', `/admin/users/${demoId}/active`, { active: false });
rmSync(DIR, { recursive: true, force: true });
});

it('the sign-in page says it is a demo and shows the login to use', async () => {
const page = await buildDriver({ signedIn: false });
pages.push(page);
await page.goto(BASE_URL);
await waitFor(page, byTestId('auth-demo-hint'), 30_000);
expect(await page.locator(byTestId('auth-demo-hint')).innerText()).toContain(HINT);
await saveScreenshot(page, 'demo-sign-in');

// A visitor signs in with exactly what the page shows.
await page.fill(byTestId('auth-login-email'), DEMO_EMAIL);
await page.fill('#auth-password', DEMO_PASSWORD);
await clickWhen(page, byTestId('auth-login-submit'));
await page.waitForSelector(`${byTestId('onboarding-skip')}, ${byTestId('demo-banner')}`, { timeout: 30_000 });
const skip = page.locator(byTestId('onboarding-skip'));
if (await skip.count()) await skip.click();
await waitFor(page, byTestId('demo-banner'), 15_000);
expect(await page.locator(byTestId('demo-banner')).innerText()).toMatch(/Nothing here can be saved/);
await saveScreenshot(page, 'demo-banner');
}, 120_000);

it('a visitor queries the sample database the admin set up', async () => {
const page = pages[0]!;
const sql = new SqlEditorPage(page);
await sql.openView();
await sql.checkConnection(CONNECTION);
await sql.setSql('SELECT name FROM planets ORDER BY id;');
await sql.run();
await sql.waitForResults();
const text = await sql.resultsText();
for (const planet of ['Mercury', 'Venus', 'Earth']) expect(text).toContain(planet);
await saveScreenshot(page, 'demo-query');
}, 120_000);

it('saving anything is refused, and the profile is locked', async () => {
const refused = [
await api('POST', '/connections', { name: 'mine', dialect: 'sqlite', option: { database: '/tmp/x.db' } }, demoCookie),
await api('POST', '/workspaces', { name: 'mine' }, demoCookie),
await api('POST', '/auth/sign-out-others', {}, demoCookie),
];
for (const r of refused) {
expect(r.status, JSON.stringify(r.json)).toBe(403);
expect(r.json.error).toMatch(/read-only demo/);
}
// A write SQL statement is refused by the permissions the Demo role lacks.
const conn = (await api('GET', '/connections', undefined, demoCookie)).json.connections.find(
(c: { name: string }) => c.name === CONNECTION
);
const write = await api('POST', '/sql/execute', { connectionId: conn.id, statements: ['DELETE FROM planets'] }, demoCookie);
expect(write.status, JSON.stringify(write.json)).toBe(403);
expect(execFileSync('sqlite3', [DB, 'SELECT COUNT(*) FROM planets'], { encoding: 'utf8' }).trim()).toBe('3');

// The browser offers no way to sign the other visitors out.
const page = pages[0]!;
await page.goto(BASE_URL);
await clickWhen(page, byTestId('profile-menu-trigger'));
await waitFor(page, byTestId('profile-sign-out'), 10_000);
expect(await page.locator(byTestId('profile-sign-out-others')).count()).toBe(0);
}, 120_000);
});
2 changes: 2 additions & 0 deletions apps/web/src/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { apiGetPreferences } from '@/shared/api/authApi';
import { ToastHost } from '@/app/shell/ToastHost';
import { AlertCircle, AlertTriangle, Loader2, X } from 'lucide-react';
import { BackendOfflineBanner } from '@/app/shell/BackendOfflineBanner';
import { DemoBanner } from '@/app/shell/DemoBanner';
import { CommandPalette } from '@/app/shell/CommandPalette';
import { WORKSPACE_VIEWS, redirectFor } from '@/app/features/featureRegistry';
import type { ActiveView } from '@/platform/viewIds';
Expand Down Expand Up @@ -62,6 +63,7 @@ const Workspace: React.FC = () => {
{/* Above every other banner: when the backend is gone, nothing else on
screen is trustworthy and no other message explains why. */}
<BackendOfflineBanner />
<DemoBanner />

{accountReminder && (
<Suspense fallback={null}>
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/frontend/app/shell/DemoBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { useAuthStore } from '@/platform/authStore';
import { DemoBanner } from './DemoBanner';

const signIn = (role: string, demo: { on: boolean } | null) =>
useAuthStore.setState({
user: { id: 'u', email: 'u@example.com', onboardingCompleted: true, role, permissions: [] },
status: 'ready',
demo,
} as never);

beforeEach(() => {
useAuthStore.setState({ demo: null });
});

describe('DemoBanner', () => {
it('tells a demo visitor what works and that nothing is saved', () => {
signIn('demo', { on: true });
render(<DemoBanner />);
expect(screen.getByTestId('demo-banner').textContent).toMatch(/browse, compare, and run queries.*Nothing here can be saved/);
});

it('reminds an admin that demo mode is on', () => {
signIn('admin', { on: true });
render(<DemoBanner />);
expect(screen.getByTestId('demo-banner').textContent).toMatch(/Demo mode is on/);
});

it('is absent when demo mode is off, or the server did not say', () => {
signIn('demo', { on: false });
const { unmount } = render(<DemoBanner />);
expect(screen.queryByTestId('demo-banner')).toBeNull();
unmount();

signIn('demo', null);
render(<DemoBanner />);
expect(screen.queryByTestId('demo-banner')).toBeNull();
});
});
33 changes: 33 additions & 0 deletions apps/web/src/frontend/app/shell/DemoBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* Shown on every page while an admin has demo mode on, so a visitor knows
* why saving does nothing before the first refusal tells them.
*/
import React from 'react';
import { Eye } from 'lucide-react';
import { isDemoRole } from '@foxschema/shared';
import { useAuthStore } from '@/platform/authStore';

export const DemoBanner: React.FC = () => {
const demo = useAuthStore((s) => s.demo);
const role = useAuthStore((s) => s.user?.role);
if (!demo?.on) return null;
return (
<div
data-testid="demo-banner"
role="status"
className="bg-sky-950/60 border-b border-sky-500/30 px-6 py-1.5 flex items-center gap-2 text-xs text-sky-100"
>
<Eye className="w-3.5 h-3.5 text-sky-300 shrink-0" />
<span className="font-bold">Demo</span>
<span className="text-sky-200/90">
{isDemoRole(role)
? 'Explore the sample databases: browse, compare, and run queries. Nothing here can be saved or changed.'
: 'Demo mode is on: visitors see this banner, and the sign-in page shows the demo login.'}
</span>
</div>
);
};
10 changes: 10 additions & 0 deletions apps/web/src/frontend/app/shell/ProfileMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ describe('ProfileMenu', () => {
await vi.waitFor(() => expect(toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Signed out 2 other sessions' })));
});

it('does not offer a demo login to sign the other visitors out', () => {
useAuthStore.setState({
user: { id: 'demo', email: 'demo@example.com', onboardingCompleted: true, role: 'demo', permissions: [] },
} as never);
render(<ProfileMenu />);
fireEvent.click(screen.getByTestId('profile-menu-trigger'));
expect(screen.getByTestId('profile-sign-out')).toBeTruthy();
expect(screen.queryByTestId('profile-sign-out-others')).toBeNull();
});

it('opens Access control from the avatar menu for the local admin', async () => {
render(<ProfileMenu />);
fireEvent.click(screen.getByTestId('profile-menu-trigger'));
Expand Down
Loading
Loading