diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c2dec33f0b..5f837a40a1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -797,8 +797,17 @@ jobs: - name: Build run: pnpm build working-directory: packages/boxel-cli - - name: Run tests - run: pnpm test + - name: Run unit tests + run: pnpm test:unit + working-directory: packages/boxel-cli + - name: Start Matrix + run: pnpm start:matrix + working-directory: packages/realm-server + - name: Create realm users + run: pnpm register-realm-users + working-directory: packages/matrix + - name: Run integration tests + run: pnpm test:integration working-directory: packages/boxel-cli deploy: diff --git a/packages/boxel-cli/package.json b/packages/boxel-cli/package.json index 009c3d9f33..3030d5dd59 100644 --- a/packages/boxel-cli/package.json +++ b/packages/boxel-cli/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@cardstack/local-types": "workspace:*", + "@cardstack/postgres": "workspace:*", "@cardstack/runtime-common": "workspace:*", "@types/node": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:", @@ -59,6 +60,8 @@ "lint:js:fix": "eslint . --report-unused-disable-directives --fix", "lint:types": "tsc --noEmit", "test": "vitest run", + "test:unit": "vitest run --exclude tests/integration/**", + "test:integration": "./tests/scripts/run-integration-with-test-pg.sh", "test:watch": "vitest", "version:patch": "npm version patch", "version:minor": "npm version minor", diff --git a/packages/boxel-cli/src/commands/realm/create.ts b/packages/boxel-cli/src/commands/realm/create.ts new file mode 100644 index 0000000000..2ecf80685f --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/create.ts @@ -0,0 +1,131 @@ +import type { Command } from 'commander'; +import { + iconURLFor, + getRandomBackgroundURL, +} from '@cardstack/runtime-common/realm-display-defaults'; +import { + getProfileManager, + type ProfileManager, +} from '../../lib/profile-manager'; +import { FG_GREEN, FG_CYAN, DIM, RESET } from '../../lib/colors'; + +const REALM_NAME_PATTERN = /^[a-z0-9-]+$/; + +export function registerCreateCommand(realm: Command): void { + realm + .command('create') + .description('Create a new realm on the realm server') + .argument('', 'realm name (lowercase, numbers, hyphens only)') + .argument('', 'display name for the realm') + .option('--background ', 'background image URL') + .option('--icon ', 'icon image URL') + .action( + async ( + realmName: string, + displayName: string, + options: CreateOptions, + ) => { + await createRealm(realmName, displayName, options); + }, + ); +} + +export interface CreateOptions { + background?: string; + icon?: string; + profileManager?: ProfileManager; +} + +export async function createRealm( + realmName: string, + displayName: string, + options: CreateOptions, +): Promise { + if (!REALM_NAME_PATTERN.test(realmName)) { + console.error( + 'Error: realm name must contain only lowercase letters, numbers, and hyphens', + ); + process.exit(1); + } + + let pm = options.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + console.error( + 'Error: no active profile. Run `boxel profile add` to create one.', + ); + process.exit(1); + } + + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + + let attributes: Record = { + endpoint: realmName, + name: displayName, + backgroundURL: options.background ?? getRandomBackgroundURL(), + iconURL: options.icon ?? iconURLFor(displayName) ?? iconURLFor(realmName), + }; + + let response: Response; + try { + response = await pm.authedFetch(`${realmServerUrl}/_create-realm`, { + method: 'POST', + headers: { 'Content-Type': 'application/vnd.api+json' }, + body: JSON.stringify({ + data: { type: 'realm', attributes }, + }), + }); + } catch (e: unknown) { + console.error(`Error: failed to connect to realm server`); + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + + if (!response.ok) { + let errorBody = await response.text(); + console.error(`Error: realm server returned ${response.status}`); + if (errorBody) { + console.error(errorBody); + } + process.exit(1); + } + + let result = await response.json(); + let realmUrl = result?.data?.id; + let normalizedRealmUrl = realmUrl ? ensureTrailingSlash(realmUrl) : undefined; + + if (normalizedRealmUrl) { + try { + let serverToken = await pm.getOrRefreshServerToken(); + let token = await pm.fetchAndStoreRealmToken( + normalizedRealmUrl, + serverToken, + ); + if (!token) { + console.error( + `${DIM}Warning: realm created but JWT not found in auth response.${RESET}`, + ); + } + } catch { + console.error( + `${DIM}Warning: realm created but could not obtain realm JWT.${RESET}`, + ); + } + + try { + await pm.addToUserRealms(normalizedRealmUrl); + } catch { + console.error( + `${DIM}Warning: could not register realm in dashboard. It may not appear until next login.${RESET}`, + ); + } + } + + console.log( + `${FG_GREEN}Realm created:${RESET} ${FG_CYAN}${realmUrl ?? realmName}${RESET}`, + ); +} + +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} diff --git a/packages/boxel-cli/src/commands/realm/index.ts b/packages/boxel-cli/src/commands/realm/index.ts new file mode 100644 index 0000000000..a435659a41 --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/index.ts @@ -0,0 +1,10 @@ +import type { Command } from 'commander'; +import { registerCreateCommand } from './create'; + +export function registerRealmCommand(program: Command): void { + let realm = program + .command('realm') + .description('Manage realms on the realm server'); + + registerCreateCommand(realm); +} diff --git a/packages/boxel-cli/src/index.ts b/packages/boxel-cli/src/index.ts index bec803f169..d19bfaf97d 100644 --- a/packages/boxel-cli/src/index.ts +++ b/packages/boxel-cli/src/index.ts @@ -3,6 +3,7 @@ import { Command } from 'commander'; import { readFileSync } from 'fs'; import { resolve } from 'path'; import { profileCommand } from './commands/profile'; +import { registerRealmCommand } from './commands/realm/index'; const pkg = JSON.parse( readFileSync(resolve(__dirname, '../package.json'), 'utf-8'), @@ -39,4 +40,6 @@ program }, ); +registerRealmCommand(program); + program.parse(); diff --git a/packages/boxel-cli/src/lib/auth.ts b/packages/boxel-cli/src/lib/auth.ts new file mode 100644 index 0000000000..830d684a32 --- /dev/null +++ b/packages/boxel-cli/src/lib/auth.ts @@ -0,0 +1,169 @@ +export interface MatrixAuth { + accessToken: string; + deviceId: string; + userId: string; + matrixUrl: string; +} + +export type RealmTokens = Record; + +interface MatrixLoginResponse { + access_token: string; + device_id: string; + user_id: string; +} + +import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; + +export async function matrixLogin( + matrixUrl: string, + username: string, + password: string, +): Promise { + let response = await fetch( + new URL('_matrix/client/v3/login', matrixUrl).href, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identifier: { type: 'm.id.user', user: username }, + password, + type: 'm.login.password', + }), + }, + ); + + let json = (await response.json()) as MatrixLoginResponse; + if (!response.ok) { + throw new Error( + `Matrix login failed: ${response.status} ${JSON.stringify(json)}`, + ); + } + + return { + accessToken: json.access_token, + deviceId: json.device_id, + userId: json.user_id, + matrixUrl, + }; +} + +async function getOpenIdToken( + matrixAuth: MatrixAuth, +): Promise> { + let response = await fetch( + new URL( + `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/openid/request_token`, + matrixAuth.matrixUrl, + ).href, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${matrixAuth.accessToken}`, + }, + body: '{}', + }, + ); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`OpenID token request failed: ${response.status} ${text}`); + } + + return (await response.json()) as Record; +} + +export async function getRealmServerToken( + matrixAuth: MatrixAuth, + realmServerUrl: string, +): Promise { + let openIdToken = await getOpenIdToken(matrixAuth); + let url = `${realmServerUrl.replace(/\/$/, '')}/_server-session`; + + let response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(openIdToken), + }); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`Realm server session failed: ${response.status} ${text}`); + } + + let token = response.headers.get('Authorization'); + if (!token) { + throw new Error( + 'Realm server session response did not include an Authorization header', + ); + } + return token; +} + +export async function getRealmTokens( + realmServerUrl: string, + serverToken: string, +): Promise { + let url = `${realmServerUrl.replace(/\/$/, '')}/_realm-auth`; + + let response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: serverToken, + }, + }); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`Realm auth lookup failed: ${response.status} ${text}`); + } + + return (await response.json()) as RealmTokens; +} + +export async function addRealmToMatrixAccountData( + matrixAuth: MatrixAuth, + realmUrl: string, +): Promise { + let accountDataUrl = new URL( + `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`, + matrixAuth.matrixUrl, + ).href; + + let existingRealms: string[] = []; + try { + let getResponse = await fetch(accountDataUrl, { + headers: { Authorization: `Bearer ${matrixAuth.accessToken}` }, + }); + if (getResponse.ok) { + let data = (await getResponse.json()) as { realms?: string[] }; + existingRealms = Array.isArray(data.realms) ? [...data.realms] : []; + } + } catch { + // Best-effort — if we can't read existing realms, start fresh + } + + if (!existingRealms.includes(realmUrl)) { + existingRealms.push(realmUrl); + let putResponse = await fetch(accountDataUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${matrixAuth.accessToken}`, + }, + body: JSON.stringify({ realms: existingRealms }), + }); + if (!putResponse.ok) { + let text = await putResponse.text(); + throw new Error( + `Failed to update Matrix account data: ${putResponse.status} ${text}`, + ); + } + } +} diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index b67eebb339..2f5bebb58e 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -2,6 +2,13 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { FG_YELLOW, FG_CYAN, FG_MAGENTA, DIM, BOLD, RESET } from './colors'; +import { + matrixLogin, + getRealmServerToken as fetchRealmServerToken, + getRealmTokens, + addRealmToMatrixAccountData, + type MatrixAuth, +} from './auth'; const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli'); const PROFILES_FILENAME = 'profiles.json'; @@ -11,6 +18,8 @@ export interface Profile { matrixUrl: string; realmServerUrl: string; password: string; // Stored in plaintext - file should have restricted permissions, this will be updated in CS-10642 + realmTokens?: Record; + realmServerToken?: string; } export interface ProfilesConfig { @@ -281,6 +290,114 @@ export class ProfileManager { return true; } + setRealmToken(realmUrl: string, token: string): void { + let active = this.getActiveProfile(); + if (!active) { + return; + } + if (!active.profile.realmTokens) { + active.profile.realmTokens = {}; + } + active.profile.realmTokens[realmUrl] = token; + this.saveConfig(); + } + + getRealmToken(realmUrl: string): string | undefined { + let active = this.getActiveProfile(); + return active?.profile.realmTokens?.[realmUrl]; + } + + setRealmServerToken(token: string): void { + let active = this.getActiveProfile(); + if (!active) { + return; + } + active.profile.realmServerToken = token; + this.saveConfig(); + } + + getRealmServerToken(): string | undefined { + let active = this.getActiveProfile(); + return active?.profile.realmServerToken; + } + + private async loginToMatrix(): Promise { + let active = this.getActiveProfile(); + if (!active) { + throw new Error('No active profile'); + } + let { id, profile } = active; + let username = getUsernameFromMatrixId(id); + return matrixLogin(profile.matrixUrl, username, profile.password); + } + + async getOrRefreshServerToken(): Promise { + let cached = this.getRealmServerToken(); + if (cached) { + return cached; + } + let matrixAuth = await this.loginToMatrix(); + let active = this.getActiveProfile()!; + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + let token = await fetchRealmServerToken(matrixAuth, realmServerUrl); + this.setRealmServerToken(token); + return token; + } + + async refreshServerToken(): Promise { + let matrixAuth = await this.loginToMatrix(); + let active = this.getActiveProfile()!; + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + let token = await fetchRealmServerToken(matrixAuth, realmServerUrl); + this.setRealmServerToken(token); + return token; + } + + async authedFetch( + input: string | URL | Request, + init?: RequestInit, + ): Promise { + let token = await this.getOrRefreshServerToken(); + let baseHeaders = + input instanceof Request ? new Headers(input.headers) : new Headers(); + let initHeaders = new Headers(init?.headers); + for (let [key, value] of initHeaders) { + baseHeaders.set(key, value); + } + if (!baseHeaders.has('Authorization')) { + baseHeaders.set('Authorization', token); + } + + let response = await fetch(input, { ...init, headers: baseHeaders }); + + if (response.status === 401) { + token = await this.refreshServerToken(); + baseHeaders.set('Authorization', token); + response = await fetch(input, { ...init, headers: baseHeaders }); + } + + return response; + } + + async fetchAndStoreRealmToken( + realmUrl: string, + serverToken: string, + ): Promise { + let active = this.getActiveProfile()!; + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + let tokens = await getRealmTokens(realmServerUrl, serverToken); + let token = tokens[realmUrl]; + if (token) { + this.setRealmToken(realmUrl, token); + } + return token; + } + + async addToUserRealms(realmUrl: string): Promise { + let matrixAuth = await this.loginToMatrix(); + await addRealmToMatrixAccountData(matrixAuth, realmUrl); + } + async migrateFromEnv(): Promise<{ profileId: string; created: boolean; diff --git a/packages/boxel-cli/tests/commands/profile.test.ts b/packages/boxel-cli/tests/commands/profile.test.ts index f1769dbc0a..25c8cc2ea2 100644 --- a/packages/boxel-cli/tests/commands/profile.test.ts +++ b/packages/boxel-cli/tests/commands/profile.test.ts @@ -211,6 +211,77 @@ describe('ProfileManager', () => { }); }); +describe('token storage', () => { + let tmpDir: string; + let manager: ProfileManager; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-profile-test-')); + manager = new ProfileManager(tmpDir); + await manager.addProfile( + '@test:localhost', + 'pass', + 'Test', + 'http://localhost:8008', + 'http://localhost:4201/', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('stores and retrieves a realm token', () => { + manager.setRealmToken('http://localhost:4201/my-realm/', 'jwt-123'); + expect(manager.getRealmToken('http://localhost:4201/my-realm/')).toBe( + 'jwt-123', + ); + }); + + it('stores and retrieves a realm server token', () => { + manager.setRealmServerToken('server-jwt-456'); + expect(manager.getRealmServerToken()).toBe('server-jwt-456'); + }); + + it('persists realm tokens to disk', () => { + manager.setRealmToken('http://localhost:4201/my-realm/', 'jwt-123'); + + let manager2 = new ProfileManager(tmpDir); + expect(manager2.getRealmToken('http://localhost:4201/my-realm/')).toBe( + 'jwt-123', + ); + }); + + it('persists realm server token to disk', () => { + manager.setRealmServerToken('server-jwt-456'); + + let manager2 = new ProfileManager(tmpDir); + expect(manager2.getRealmServerToken()).toBe('server-jwt-456'); + }); + + it('stores multiple realm tokens independently', () => { + manager.setRealmToken('http://localhost:4201/realm-a/', 'jwt-a'); + manager.setRealmToken('http://localhost:4201/realm-b/', 'jwt-b'); + + expect(manager.getRealmToken('http://localhost:4201/realm-a/')).toBe( + 'jwt-a', + ); + expect(manager.getRealmToken('http://localhost:4201/realm-b/')).toBe( + 'jwt-b', + ); + }); + + it('returns undefined for unknown realm token', () => { + expect( + manager.getRealmToken('http://localhost:4201/nonexistent/'), + ).toBeUndefined(); + }); + + it('returns undefined for realm server token when not set', () => { + expect(manager.getRealmServerToken()).toBeUndefined(); + }); +}); + describe('environment helpers', () => { it('detects staging environment', () => { expect(getEnvironmentFromMatrixId('@user:stack.cards')).toBe('staging'); diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts new file mode 100644 index 0000000000..99249af38a --- /dev/null +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -0,0 +1,133 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ProfileManager } from '../../src/lib/profile-manager'; +import { + prepareTestDB, + createTestPgAdapter, + createVirtualNetwork, + runTestRealmServer, + closeServer, + matrixURL, + matrixRegistrationSecret, +} from '#realm-server/tests/helpers/index'; +import { registerUser } from '#realm-server/synapse'; +import { + PgQueuePublisher, + PgQueueRunner, + type PgAdapter, +} from '@cardstack/postgres'; +import type { Prerenderer } from '@cardstack/runtime-common'; +import type { Server } from 'http'; + +// CLI tests don't need card rendering — stub out the prerenderer +// so we don't launch Chrome. +const noopPrerenderer: Prerenderer = { + prerenderCard: async () => ({ html: '', status: 200 }) as any, + prerenderModule: async () => ({ html: '', status: 200 }) as any, + prerenderFileExtract: async () => ({ html: '', status: 200 }) as any, + prerenderFileRender: async () => ({ html: '', status: 200 }) as any, + runCommand: async () => ({ status: 'ready' }), +}; + +export const TEST_REALM_SERVER_URL = 'http://127.0.0.1:4446'; + +const TEST_USERNAME = `cli-test-${Date.now()}`; +const TEST_PASSWORD = 'test-password-for-cli'; + +let testRealmHttpServer: Server | undefined; +let dbAdapter: PgAdapter | undefined; +let publisher: PgQueuePublisher | undefined; +let runner: PgQueueRunner | undefined; + +export async function startTestRealmServer(): Promise { + prepareTestDB(); + dbAdapter = await createTestPgAdapter(); + publisher = new PgQueuePublisher(dbAdapter); + runner = new PgQueueRunner({ + adapter: dbAdapter, + workerId: 'cli-test-worker', + }); + + let virtualNetwork = createVirtualNetwork(); + let realmURL = new URL(`${TEST_REALM_SERVER_URL}/test/`); + + let { testRealmHttpServer: server } = await runTestRealmServer({ + testRealmDir: fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-cli-realm-')), + realmsRootPath: fs.mkdtempSync( + path.join(os.tmpdir(), 'boxel-cli-realms-root-'), + ), + realmURL, + virtualNetwork, + publisher, + runner, + dbAdapter, + matrixURL, + permissions: { + '*': ['read', 'write'], + }, + prerenderer: noopPrerenderer, + }); + + testRealmHttpServer = server; + + // Register a test user in Synapse so CLI can do a full Matrix login + await registerUser({ + matrixURL, + displayname: 'CLI Test User', + username: TEST_USERNAME, + password: TEST_PASSWORD, + registrationSecret: matrixRegistrationSecret, + }); +} + +export async function stopTestRealmServer(): Promise { + if (testRealmHttpServer) { + await closeServer(testRealmHttpServer); + testRealmHttpServer = undefined; + } + if (publisher) { + await publisher.destroy(); + publisher = undefined; + } + if (runner) { + await runner.destroy(); + runner = undefined; + } + if (dbAdapter) { + await dbAdapter.close(); + dbAdapter = undefined; + } +} + +export function createTestProfileDir(): { + dir: string; + cleanup: () => void; + profileManager: ProfileManager; +} { + let dir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-cli-test-')); + let profileManager = new ProfileManager(dir); + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + profileManager, + }; +} + +export async function setupTestProfile(pm: ProfileManager): Promise { + let matrixId = `@${TEST_USERNAME}:localhost`; + await pm.addProfile( + matrixId, + TEST_PASSWORD, + 'CLI Test User', + matrixURL.href, + `${TEST_REALM_SERVER_URL}/`, + ); + return matrixId; +} + +export function uniqueRealmName(): string { + let ts = Date.now().toString(36); + let rand = Math.random().toString(36).slice(2, 6); + return `cli-test-${ts}-${rand}`; +} diff --git a/packages/boxel-cli/tests/helpers/setup-realm-server.ts b/packages/boxel-cli/tests/helpers/setup-realm-server.ts new file mode 100644 index 0000000000..7cba1404b6 --- /dev/null +++ b/packages/boxel-cli/tests/helpers/setup-realm-server.ts @@ -0,0 +1,3 @@ +import { makeLogDefinitions } from '@cardstack/runtime-common'; + +(globalThis as any)._logDefinitions = makeLogDefinitions('*=warn'); diff --git a/packages/boxel-cli/tests/integration/realm-create.test.ts b/packages/boxel-cli/tests/integration/realm-create.test.ts new file mode 100644 index 0000000000..80320dd8f3 --- /dev/null +++ b/packages/boxel-cli/tests/integration/realm-create.test.ts @@ -0,0 +1,65 @@ +import '../helpers/setup-realm-server'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRealm } from '../../src/commands/realm/create'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + setupTestProfile, + uniqueRealmName, +} from '../helpers/integration'; +import type { ProfileManager } from '../../src/lib/profile-manager'; + +let profileManager: ProfileManager; +let cleanup: () => void; + +beforeAll(async () => { + await startTestRealmServer(); + + let testProfile = createTestProfileDir(); + profileManager = testProfile.profileManager; + cleanup = testProfile.cleanup; + await setupTestProfile(profileManager); +}); + +afterAll(async () => { + cleanup?.(); + await stopTestRealmServer(); +}); + +describe('realm create (integration)', () => { + it('creates a realm and stores the JWT in the profile', async () => { + let realmName = uniqueRealmName(); + + await createRealm(realmName, `Test ${realmName}`, { profileManager }); + + let active = profileManager.getActiveProfile()!; + let realmTokens = active.profile.realmTokens ?? {}; + let storedToken = Object.entries(realmTokens).find(([url]) => + url.includes(realmName), + )?.[1]; + + expect(storedToken).toBeDefined(); + expect(storedToken!.length).toBeGreaterThan(0); + expect(profileManager.getRealmServerToken()).toBeDefined(); + }); + + it('creates another realm reusing the cached server token', async () => { + let cachedToken = profileManager.getRealmServerToken(); + expect(cachedToken).toBeDefined(); + + let realmName = uniqueRealmName(); + + await createRealm(realmName, `Test ${realmName}`, { profileManager }); + + // Server token was reused, not re-fetched + expect(profileManager.getRealmServerToken()).toBe(cachedToken); + + let active = profileManager.getActiveProfile()!; + let realmTokens = active.profile.realmTokens ?? {}; + let storedToken = Object.entries(realmTokens).find(([url]) => + url.includes(realmName), + )?.[1]; + expect(storedToken).toBeDefined(); + }); +}); diff --git a/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh b/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh new file mode 100755 index 0000000000..64f9c61850 --- /dev/null +++ b/packages/boxel-cli/tests/scripts/run-integration-with-test-pg.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REALM_SERVER_SCRIPTS="$(cd "$SCRIPT_DIR/../../../realm-server/tests/scripts" && pwd)" + +"${REALM_SERVER_SCRIPTS}/prepare-test-pg.sh" +trap '"${REALM_SERVER_SCRIPTS}/stop-test-pg.sh" >/dev/null 2>&1 || true' EXIT INT TERM + +NODE_NO_WARNINGS=1 \ +PGPORT=55436 \ + vitest run --pool=forks --poolOptions.forks.singleFork tests/integration/** diff --git a/packages/boxel-cli/tsconfig.json b/packages/boxel-cli/tsconfig.json index 119f4a05aa..b2e7cf4c9c 100644 --- a/packages/boxel-cli/tsconfig.json +++ b/packages/boxel-cli/tsconfig.json @@ -22,5 +22,5 @@ "types": ["@cardstack/local-types", "node"] }, "include": ["./**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "tests/helpers/integration.ts", "tests/helpers/setup-realm-server.ts", "tests/integration"] } diff --git a/packages/boxel-cli/vitest.config.mjs b/packages/boxel-cli/vitest.config.mjs index c7d25ff9c4..f83c9f75dd 100644 --- a/packages/boxel-cli/vitest.config.mjs +++ b/packages/boxel-cli/vitest.config.mjs @@ -1,11 +1,18 @@ import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; export default defineConfig({ + resolve: { + alias: { + '#realm-server': resolve(import.meta.dirname, '../realm-server'), + }, + }, test: { globals: true, setupFiles: [], include: ['**/tests/**/*.ts'], exclude: ['tests/helpers/**', 'node_modules'], + testTimeout: 30000, sequence: { hooks: 'list', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c311b0c39f..fd2356d205 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1035,6 +1035,9 @@ importers: '@cardstack/local-types': specifier: workspace:* version: link:../local-types + '@cardstack/postgres': + specifier: workspace:* + version: link:../postgres '@cardstack/runtime-common': specifier: workspace:* version: link:../runtime-common @@ -6097,6 +6100,7 @@ packages: '@tsconfig/ember@3.0.1': resolution: {integrity: sha512-IBoECN9o9StxTZSy12eNSPdqiH5VzngD5Qx9YQDfteiXk9XyJhnyRQuBoU/MQCVnqau9fJpgKoA8Sy/0qItFXw==} + deprecated: Please use @ember/app-tsconfig or @ember/library-tsconfig instead. These live at https://github.com/ember-cli/tsconfigs '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -6803,6 +6807,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -7370,7 +7375,7 @@ packages: basic-ftp@5.1.0: resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.0, please upgrade + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -10415,7 +10420,7 @@ packages: glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}