create-ortha-app 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +277 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/lib/conditionals.d.ts +37 -0
- package/dist/lib/conditionals.d.ts.map +1 -0
- package/dist/lib/conditionals.js +115 -0
- package/dist/lib/features.d.ts +185 -0
- package/dist/lib/features.d.ts.map +1 -0
- package/dist/lib/features.js +328 -0
- package/dist/lib/template.d.ts +46 -0
- package/dist/lib/template.d.ts.map +1 -0
- package/dist/lib/template.js +115 -0
- package/dist/lib/ui.d.ts +76 -0
- package/dist/lib/ui.d.ts.map +1 -0
- package/dist/lib/ui.js +310 -0
- package/dist/lib/validate.d.ts +13 -0
- package/dist/lib/validate.d.ts.map +1 -0
- package/dist/lib/validate.js +51 -0
- package/package.json +37 -0
- package/templates/default/README.md.tmpl +199 -0
- package/templates/default/_gitignore +8 -0
- package/templates/default/apps/admin/index.html +38 -0
- package/templates/default/apps/admin/src/main.tsx +5 -0
- package/templates/default/apps/admin/src/plugins.spec.ts +58 -0
- package/templates/default/apps/admin/src/plugins.ts +57 -0
- package/templates/default/apps/admin/src/styles.css +254 -0
- package/templates/default/apps/admin/tsconfig.json +29 -0
- package/templates/default/apps/admin/vite.config.mts +67 -0
- package/templates/default/apps/admin-e2e/playwright.config.ts +51 -0
- package/templates/default/apps/admin-e2e/src/auth.spec.ts +55 -0
- package/templates/default/apps/admin-e2e/src/support/seed.ts +62 -0
- package/templates/default/apps/admin-e2e/tsconfig.json +23 -0
- package/templates/default/apps/server/jest.config.js +38 -0
- package/templates/default/apps/server/jest.setup.js +20 -0
- package/templates/default/apps/server/ortha.config.ts +505 -0
- package/templates/default/apps/server/src/main.ts +26 -0
- package/templates/default/apps/server/src/plugins.spec.ts +71 -0
- package/templates/default/apps/server/src/plugins.ts +229 -0
- package/templates/default/apps/server/tsconfig.json +31 -0
- package/templates/default/apps/server-e2e/jest.config.js +47 -0
- package/templates/default/apps/server-e2e/src/api.spec.ts +107 -0
- package/templates/default/apps/server-e2e/src/global-setup.ts +41 -0
- package/templates/default/apps/server-e2e/src/jest.setup.ts +28 -0
- package/templates/default/apps/server-e2e/src/support/db.ts +143 -0
- package/templates/default/apps/server-e2e/src/support/test-app.ts +64 -0
- package/templates/default/apps/server-e2e/tsconfig.json +26 -0
- package/templates/default/docker-compose.yml +21 -0
- package/templates/default/env.tmpl +140 -0
- package/templates/default/package.json.tmpl +51 -0
- package/templates/default/tsconfig.json +18 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { ServerPlugin } from '@orthacms/bootstrap-server';
|
|
2
|
+
import { ActivityPlugin } from '@orthacms/activity-server';
|
|
3
|
+
import { ContentPlugin } from '@orthacms/content-server';
|
|
4
|
+
import { DatabasePlugin } from '@orthacms/database';
|
|
5
|
+
import { I18nServerPlugin } from '@orthacms/i18n-server';
|
|
6
|
+
import { IdentityPlugin } from '@orthacms/identity-server';
|
|
7
|
+
// ortha:if sso-oidc
|
|
8
|
+
import type { SsoRegistration } from '@orthacms/identity-domain';
|
|
9
|
+
import { createOidcProvider } from '@orthacms/identity-provider-oidc';
|
|
10
|
+
// ortha:end
|
|
11
|
+
import { MediaServerPlugin } from '@orthacms/media-server';
|
|
12
|
+
// ortha:if media-local
|
|
13
|
+
import { createLocalStorageProvider } from '@orthacms/media-provider-local';
|
|
14
|
+
// ortha:end
|
|
15
|
+
// ortha:if media-s3
|
|
16
|
+
import { createS3StorageProvider } from '@orthacms/media-provider-s3';
|
|
17
|
+
// ortha:end
|
|
18
|
+
// ortha:if media-azure
|
|
19
|
+
import { createAzureStorageProvider } from '@orthacms/media-provider-azure';
|
|
20
|
+
// ortha:end
|
|
21
|
+
// ortha:if media-gcs
|
|
22
|
+
import { createGcsStorageProvider } from '@orthacms/media-provider-gcs';
|
|
23
|
+
// ortha:end
|
|
24
|
+
// ortha:if media-vercel-blob
|
|
25
|
+
import { createVercelBlobStorageProvider } from '@orthacms/media-provider-vercel-blob';
|
|
26
|
+
// ortha:end
|
|
27
|
+
import { UsersPlugin } from '@orthacms/users-server';
|
|
28
|
+
// ortha:if graphql
|
|
29
|
+
import { ContentGraphqlPlugin } from '@orthacms/content-graphql';
|
|
30
|
+
// ortha:end
|
|
31
|
+
// ortha:if mcp
|
|
32
|
+
import { McpPlugin } from '@orthacms/mcp-server';
|
|
33
|
+
// ortha:end
|
|
34
|
+
import {
|
|
35
|
+
CopilotPlugin,
|
|
36
|
+
type ProviderRegistration
|
|
37
|
+
} from '@orthacms/copilot-server';
|
|
38
|
+
import { createFakeProvider } from '@orthacms/copilot-provider-fake';
|
|
39
|
+
// ortha:if copilot-anthropic
|
|
40
|
+
import { createAnthropicProvider } from '@orthacms/copilot-provider-anthropic';
|
|
41
|
+
// ortha:end
|
|
42
|
+
// ortha:if copilot-openai
|
|
43
|
+
import { createOpenAiProvider } from '@orthacms/copilot-provider-openai';
|
|
44
|
+
// ortha:end
|
|
45
|
+
import { WorkspacesPlugin } from '@orthacms/workspaces-server';
|
|
46
|
+
import type { OrthaConfig } from '../ortha.config';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The model backends this deployment can actually reach, in preference order.
|
|
50
|
+
*
|
|
51
|
+
* **Only what is configured is registered.** `ortha.config.ts` omits a provider
|
|
52
|
+
* whose connection settings are absent, and an unconfigured backend is skipped
|
|
53
|
+
* here too: the first entry serves a run that names no provider, so a keyless
|
|
54
|
+
* one at the top of the list would be the house default and would fail on the
|
|
55
|
+
* first message.
|
|
56
|
+
*
|
|
57
|
+
* `fake` is last and unconditional. It is a shipped adapter, not test
|
|
58
|
+
* scaffolding — it needs no key and no network, so it is what makes the chat
|
|
59
|
+
* work offline, and being last it is the default only when it is the only one.
|
|
60
|
+
*/
|
|
61
|
+
// ortha:if sso-oidc
|
|
62
|
+
/**
|
|
63
|
+
* The identity providers this app can actually reach.
|
|
64
|
+
*
|
|
65
|
+
* **Only what is configured is registered.** `ortha.config.ts` omits a provider
|
|
66
|
+
* whose issuer or client id is missing, and an unconfigured one is skipped here
|
|
67
|
+
* too — it would appear on the sign-in page as a button that can only fail.
|
|
68
|
+
*
|
|
69
|
+
* Running two directories at once is another entry. The name is what
|
|
70
|
+
* `/api/auth/sso/<name>/start` and every `sso_identities` row refer to the
|
|
71
|
+
* provider by, so renaming a registration orphans its links. Register the
|
|
72
|
+
* callback URL `<publicBaseUrl>/api/auth/sso/<name>/callback` with the
|
|
73
|
+
* provider; `ssoCallbackUrl` from `@orthacms/identity-server` builds the exact
|
|
74
|
+
* string, which matters because most providers match it byte for byte.
|
|
75
|
+
*/
|
|
76
|
+
export function ssoProviders(config: OrthaConfig): SsoRegistration[] {
|
|
77
|
+
const oidc = config.plugins.identity.ssoProviders?.oidc;
|
|
78
|
+
if (!oidc) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const { name, ...settings } = oidc;
|
|
82
|
+
return [{ name, provider: createOidcProvider(settings) }];
|
|
83
|
+
}
|
|
84
|
+
// ortha:end
|
|
85
|
+
|
|
86
|
+
export function copilotProviders(config: OrthaConfig): ProviderRegistration[] {
|
|
87
|
+
const providers: ProviderRegistration[] = [];
|
|
88
|
+
// ortha:if copilot-anthropic
|
|
89
|
+
if (config.plugins.copilot.providers.claude) {
|
|
90
|
+
providers.push({
|
|
91
|
+
name: 'claude',
|
|
92
|
+
provider: createAnthropicProvider(
|
|
93
|
+
config.plugins.copilot.providers.claude
|
|
94
|
+
)
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// ortha:end
|
|
98
|
+
// ortha:if copilot-openai
|
|
99
|
+
if (config.plugins.copilot.providers.openai) {
|
|
100
|
+
providers.push({
|
|
101
|
+
name: 'openai',
|
|
102
|
+
provider: createOpenAiProvider(
|
|
103
|
+
config.plugins.copilot.providers.openai
|
|
104
|
+
)
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// ortha:end
|
|
108
|
+
providers.push({ name: 'fake', provider: createFakeProvider() });
|
|
109
|
+
|
|
110
|
+
return providers;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* This app's composition — the whole of what its API is.
|
|
115
|
+
*
|
|
116
|
+
* **The order is migration order.** Migrations are applied by walking this
|
|
117
|
+
* array, with no transaction spanning plugins, so a plugin whose tables
|
|
118
|
+
* reference another's must come after it. `WorkspacesPlugin` follows
|
|
119
|
+
* `IdentityPlugin` because its `memberships` table FK-references identity's
|
|
120
|
+
* `users`: put it first and a *fresh* `ortha migrate` fails with
|
|
121
|
+
* `relation "users" does not exist`, while an already-migrated database
|
|
122
|
+
* migrates perfectly happily — so the mistake ships and bites the next clean
|
|
123
|
+
* install. Add new plugins at the end unless you have a reason not to.
|
|
124
|
+
*
|
|
125
|
+
* Order does **not** decide dependency injection: every plugin module is
|
|
126
|
+
* global and every `onPluginInit` runs before the Nest app is created, so no
|
|
127
|
+
* provider can be constructed before the database connection is open.
|
|
128
|
+
*
|
|
129
|
+
* To add a plugin, install it and add a line. To remove one, delete its line —
|
|
130
|
+
* plugins that extend each other do so through optional ports, so removing one
|
|
131
|
+
* degrades the feature rather than failing boot.
|
|
132
|
+
*/
|
|
133
|
+
export function buildPlugins(config: OrthaConfig): ServerPlugin[] {
|
|
134
|
+
// No content types yet — see the note below. Held in a variable because
|
|
135
|
+
// the GraphQL adapter takes the plugin itself, not just its types.
|
|
136
|
+
const content = ContentPlugin({ types: [] });
|
|
137
|
+
|
|
138
|
+
return [
|
|
139
|
+
// First: the only plugin that opens a resource in `onPluginInit`.
|
|
140
|
+
DatabasePlugin({ connectionString: config.database.url }),
|
|
141
|
+
// ortha:if sso-oidc
|
|
142
|
+
// Identity, plus the identity providers this app offers. The second
|
|
143
|
+
// argument is where constructed adapters go: `ortha.config.ts` holds
|
|
144
|
+
// the typed view of the environment, and an adapter instance is not an
|
|
145
|
+
// environment value.
|
|
146
|
+
IdentityPlugin(config.plugins.identity, {
|
|
147
|
+
sso: { providers: ssoProviders(config) }
|
|
148
|
+
}),
|
|
149
|
+
// ortha:end
|
|
150
|
+
// ortha:ifnot sso-oidc
|
|
151
|
+
IdentityPlugin(config.plugins.identity),
|
|
152
|
+
// ortha:end
|
|
153
|
+
WorkspacesPlugin(),
|
|
154
|
+
ActivityPlugin(),
|
|
155
|
+
UsersPlugin(),
|
|
156
|
+
// No content types yet. Define some in `src/server/content/`, pass them
|
|
157
|
+
// here as `types`, then add a `drizzle.config.ts` pointing at them and
|
|
158
|
+
// a `migrations` descriptor so `ortha generate` / `ortha migrate` can
|
|
159
|
+
// manage their tables:
|
|
160
|
+
//
|
|
161
|
+
// ContentPlugin({
|
|
162
|
+
// types: contentTypes,
|
|
163
|
+
// migrations: {
|
|
164
|
+
// dir: () => join(process.cwd(), 'migrations'),
|
|
165
|
+
// table: '__drizzle_migrations_content'
|
|
166
|
+
// }
|
|
167
|
+
// })
|
|
168
|
+
//
|
|
169
|
+
// Until then the plugin serves its generic routes with an empty
|
|
170
|
+
// registry, and owns no tables of its own.
|
|
171
|
+
content,
|
|
172
|
+
// ortha:if graphql
|
|
173
|
+
// The same public content API over GraphQL, on /api/v1/graphql. It owns
|
|
174
|
+
// no schema and adds no credential — it reuses content's bearer guards
|
|
175
|
+
// and read services, so a token minted before it existed works against
|
|
176
|
+
// it unchanged. Taking `content` by value lets it fail boot on two
|
|
177
|
+
// content types that would collide as GraphQL names, rather than on the
|
|
178
|
+
// first request from a workspace granted both.
|
|
179
|
+
ContentGraphqlPlugin({
|
|
180
|
+
content,
|
|
181
|
+
playground: config.docs.enabled === true
|
|
182
|
+
}),
|
|
183
|
+
// ortha:end
|
|
184
|
+
// Fills the Content Library's locale extensions, so it reads after it.
|
|
185
|
+
I18nServerPlugin(config.plugins.i18n),
|
|
186
|
+
// This line is the single place that selects storage — one constructed
|
|
187
|
+
// provider, writing every upload to local disk. A deployment runs
|
|
188
|
+
// exactly one; swapping backend is swapping this expression (and the
|
|
189
|
+
// type of `media.storage` with it).
|
|
190
|
+
MediaServerPlugin({
|
|
191
|
+
// ortha:if media-local
|
|
192
|
+
provider: createLocalStorageProvider(config.plugins.media.storage),
|
|
193
|
+
// ortha:end
|
|
194
|
+
// ortha:if media-s3
|
|
195
|
+
provider: createS3StorageProvider(config.plugins.media.storage),
|
|
196
|
+
// ortha:end
|
|
197
|
+
// ortha:if media-azure
|
|
198
|
+
provider: createAzureStorageProvider(config.plugins.media.storage),
|
|
199
|
+
// ortha:end
|
|
200
|
+
// ortha:if media-gcs
|
|
201
|
+
provider: createGcsStorageProvider(config.plugins.media.storage),
|
|
202
|
+
// ortha:end
|
|
203
|
+
// ortha:if media-vercel-blob
|
|
204
|
+
provider: createVercelBlobStorageProvider(
|
|
205
|
+
config.plugins.media.storage
|
|
206
|
+
),
|
|
207
|
+
// ortha:end
|
|
208
|
+
config: config.plugins.media
|
|
209
|
+
}),
|
|
210
|
+
// Registered after workspaces (runs are workspace-scoped) and identity
|
|
211
|
+
// (runs execute as the calling user, gated on `copilot:use`). The
|
|
212
|
+
// composition root is the single place that selects a backend: the
|
|
213
|
+
// plugin never learns which adapters exist, it takes a list of named,
|
|
214
|
+
// already-constructed providers, and **the order is the setting** —
|
|
215
|
+
// there is no `defaultProvider`, the first entry serves a run that
|
|
216
|
+
// names none.
|
|
217
|
+
CopilotPlugin({
|
|
218
|
+
providers: copilotProviders(config),
|
|
219
|
+
config: config.plugins.copilot
|
|
220
|
+
}),
|
|
221
|
+
// ortha:if mcp
|
|
222
|
+
// The Model Context Protocol front door, registered LAST because it
|
|
223
|
+
// serves whatever the plugins above contributed. Off unless
|
|
224
|
+
// MCP_ENABLED=true: it hands an external agent the same content CRUD a
|
|
225
|
+
// full-scope token has.
|
|
226
|
+
McpPlugin({ config: config.plugins.mcp })
|
|
227
|
+
// ortha:end
|
|
228
|
+
];
|
|
229
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"es2023"
|
|
6
|
+
],
|
|
7
|
+
"module": "commonjs",
|
|
8
|
+
"moduleResolution": "node",
|
|
9
|
+
"outDir": "../../dist/server",
|
|
10
|
+
"rootDir": ".",
|
|
11
|
+
"types": [
|
|
12
|
+
"node"
|
|
13
|
+
],
|
|
14
|
+
"strict": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"esModuleInterop": true,
|
|
17
|
+
"forceConsistentCasingInFileNames": true,
|
|
18
|
+
"resolveJsonModule": true,
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"declaration": false,
|
|
21
|
+
"experimentalDecorators": true,
|
|
22
|
+
"emitDecoratorMetadata": true,
|
|
23
|
+
"tsBuildInfoFile": "../../dist/server/tsconfig.tsbuildinfo"
|
|
24
|
+
},
|
|
25
|
+
"include": [
|
|
26
|
+
"**/*.ts"
|
|
27
|
+
],
|
|
28
|
+
"exclude": [
|
|
29
|
+
"**/*.spec.ts"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server end-to-end tests — the real app, a real database.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `jest.config.js` on purpose: these need a database, take
|
|
5
|
+
* seconds rather than milliseconds, and must run **serially**. `npm test`
|
|
6
|
+
* stays fast and infrastructure-free; `npm run e2e:server` is the slow one you
|
|
7
|
+
* run before pushing.
|
|
8
|
+
*/
|
|
9
|
+
module.exports = {
|
|
10
|
+
displayName: 'server-e2e',
|
|
11
|
+
testEnvironment: 'node',
|
|
12
|
+
rootDir: '../..',
|
|
13
|
+
testMatch: ['<rootDir>/apps/server-e2e/src/**/*.spec.ts'],
|
|
14
|
+
globalSetup: '<rootDir>/apps/server-e2e/src/global-setup.ts',
|
|
15
|
+
setupFiles: ['<rootDir>/apps/server-e2e/src/jest.setup.ts'],
|
|
16
|
+
// One worker. The suite truncates shared tables, so parallel workers
|
|
17
|
+
// delete each other's fixtures — and the failure surfaces as whichever
|
|
18
|
+
// assertion happened to read a row mid-truncate, never as the race it is.
|
|
19
|
+
maxWorkers: 1,
|
|
20
|
+
// Booting the app migrates nothing but does open a pool and run the
|
|
21
|
+
// bootstrap seeders; the default 5s is not enough on a cold database.
|
|
22
|
+
testTimeout: 30_000,
|
|
23
|
+
transform: {
|
|
24
|
+
'^.+\\.[tj]s$': [
|
|
25
|
+
'@swc/jest',
|
|
26
|
+
{
|
|
27
|
+
jsc: {
|
|
28
|
+
target: 'es2022',
|
|
29
|
+
parser: { syntax: 'typescript', decorators: true },
|
|
30
|
+
transform: {
|
|
31
|
+
legacyDecorator: true,
|
|
32
|
+
decoratorMetadata: true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
module: { type: 'commonjs' }
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
// Jest runs CommonJS, and `createServer` pulls in the Scalar API-reference
|
|
40
|
+
// renderer, which is published as ESM only — so `node_modules` cannot be
|
|
41
|
+
// ignored wholesale or the import fails with `Unexpected token 'export'`
|
|
42
|
+
// pointing at a file nobody in this app wrote. Letting swc transform that
|
|
43
|
+
// one scope is cheaper than stubbing the module and pretending the docs
|
|
44
|
+
// route does not exist.
|
|
45
|
+
transformIgnorePatterns: ['/node_modules/(?!@scalar/)'],
|
|
46
|
+
moduleFileExtensions: ['ts', 'js', 'json']
|
|
47
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { resolveTestDatabaseUrl, resetDatabase } from './support/db';
|
|
3
|
+
import {
|
|
4
|
+
ALLOWED_ORIGIN,
|
|
5
|
+
closeTestApp,
|
|
6
|
+
createTestApp,
|
|
7
|
+
type TestApp
|
|
8
|
+
} from './support/test-app';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The API, driven through the real bootstrap.
|
|
12
|
+
*
|
|
13
|
+
* These are the checks that only mean something once every plugin is wired
|
|
14
|
+
* together — guards actually mounted, the auth flow actually issuing a cookie.
|
|
15
|
+
* Anything provable from a single function belongs in a unit test, where it
|
|
16
|
+
* runs in milliseconds and needs no database.
|
|
17
|
+
*/
|
|
18
|
+
describe('the API', () => {
|
|
19
|
+
let harness: TestApp;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
harness = await createTestApp();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
await closeTestApp(harness);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('authentication', () => {
|
|
30
|
+
it('refuses an unauthenticated read', async () => {
|
|
31
|
+
await request(harness.server).get('/api/workspaces').expect(401);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('signs in the root admin provisioned at boot', async () => {
|
|
35
|
+
const response = await request(harness.server)
|
|
36
|
+
.post('/api/auth/login')
|
|
37
|
+
.set('Origin', ALLOWED_ORIGIN)
|
|
38
|
+
.send({
|
|
39
|
+
email: process.env['ORTHA_ROOT_ADMIN_EMAIL'],
|
|
40
|
+
password: process.env['ORTHA_ROOT_ADMIN_PASSWORD']
|
|
41
|
+
})
|
|
42
|
+
.expect(201);
|
|
43
|
+
|
|
44
|
+
expect(response.headers['set-cookie']).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('accepts that session on a protected route', async () => {
|
|
48
|
+
const agent = request.agent(harness.server);
|
|
49
|
+
|
|
50
|
+
await agent
|
|
51
|
+
.post('/api/auth/login')
|
|
52
|
+
.set('Origin', ALLOWED_ORIGIN)
|
|
53
|
+
.send({
|
|
54
|
+
email: process.env['ORTHA_ROOT_ADMIN_EMAIL'],
|
|
55
|
+
password: process.env['ORTHA_ROOT_ADMIN_PASSWORD']
|
|
56
|
+
})
|
|
57
|
+
.expect(201);
|
|
58
|
+
|
|
59
|
+
await agent.get('/api/workspaces').expect(200);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects a wrong password without saying which half was wrong', async () => {
|
|
63
|
+
// Distinguishing "no such user" from "wrong password" turns the
|
|
64
|
+
// login form into an account enumerator.
|
|
65
|
+
const response = await request(harness.server)
|
|
66
|
+
.post('/api/auth/login')
|
|
67
|
+
.set('Origin', ALLOWED_ORIGIN)
|
|
68
|
+
.send({
|
|
69
|
+
email: process.env['ORTHA_ROOT_ADMIN_EMAIL'],
|
|
70
|
+
password: 'not-the-password'
|
|
71
|
+
})
|
|
72
|
+
.expect(401);
|
|
73
|
+
|
|
74
|
+
expect(JSON.stringify(response.body)).not.toMatch(/password|email/i);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('routing', () => {
|
|
79
|
+
it('answers an unknown path with a JSON 404', async () => {
|
|
80
|
+
const response = await request(harness.server)
|
|
81
|
+
.get('/api/not-a-real-endpoint')
|
|
82
|
+
.expect(404);
|
|
83
|
+
|
|
84
|
+
expect(response.body).toMatchObject({ statusCode: 404 });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The reset helper, proven on the database this suite actually uses.
|
|
91
|
+
*
|
|
92
|
+
* Every suite that seeds rows depends on this leaving the schema intact — a
|
|
93
|
+
* `TRUNCATE` that took the migration history with it would look like a
|
|
94
|
+
* migration bug in whichever suite ran next.
|
|
95
|
+
*/
|
|
96
|
+
describe('resetDatabase()', () => {
|
|
97
|
+
it('empties the tables without touching the migration history', async () => {
|
|
98
|
+
const url = resolveTestDatabaseUrl();
|
|
99
|
+
|
|
100
|
+
await expect(resetDatabase(url)).resolves.toBeUndefined();
|
|
101
|
+
|
|
102
|
+
// The app still boots, which it could not do against a dropped schema.
|
|
103
|
+
const harness = await createTestApp();
|
|
104
|
+
await request(harness.server).get('/api/workspaces').expect(401);
|
|
105
|
+
await closeTestApp(harness);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertDisposable,
|
|
3
|
+
ensureDatabase,
|
|
4
|
+
loadDotEnv,
|
|
5
|
+
resolveTestDatabaseUrl
|
|
6
|
+
} from './support/db';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates and migrates the e2e database once for the whole run.
|
|
10
|
+
*
|
|
11
|
+
* Migrations go through `applyPluginMigrations` — the same function
|
|
12
|
+
* `ortha migrate` calls — so the schema under test is the real shipped schema,
|
|
13
|
+
* applied in the same plugin order. A hand-rolled setup would drift from what
|
|
14
|
+
* the app does the first time a plugin is added.
|
|
15
|
+
*
|
|
16
|
+
* The app's config and plugin list are pulled in with **dynamic** imports, and
|
|
17
|
+
* that is load-bearing rather than stylistic: `ortha.config.ts` reads
|
|
18
|
+
* `process.env` at import time and throws without `DATABASE_URL`. A static
|
|
19
|
+
* import is hoisted above every statement here, so it would run before `.env`
|
|
20
|
+
* is loaded and before the URL is pointed at the test database — and the whole
|
|
21
|
+
* suite would fail on a variable that is sitting in a file two lines away.
|
|
22
|
+
*/
|
|
23
|
+
export default async function globalSetup(): Promise<void> {
|
|
24
|
+
loadDotEnv();
|
|
25
|
+
|
|
26
|
+
const url = resolveTestDatabaseUrl();
|
|
27
|
+
assertDisposable(url);
|
|
28
|
+
await ensureDatabase(url);
|
|
29
|
+
|
|
30
|
+
// Only now is it safe to let the config load.
|
|
31
|
+
process.env['DATABASE_URL'] = url;
|
|
32
|
+
|
|
33
|
+
const [{ applyPluginMigrations }, { default: config }, { buildPlugins }] =
|
|
34
|
+
await Promise.all([
|
|
35
|
+
import('@orthacms/cli'),
|
|
36
|
+
import('../../server/ortha.config'),
|
|
37
|
+
import('../../server/src/plugins')
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
await applyPluginMigrations(buildPlugins(config), url);
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
import { loadDotEnv, resolveTestDatabaseUrl } from './support/db';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Points this worker at the e2e database **before any module loads**.
|
|
6
|
+
*
|
|
7
|
+
* `ortha.config.ts` reads `process.env` at import time, so this has to be a
|
|
8
|
+
* `setupFiles` entry — those run before the module registry is touched, unlike
|
|
9
|
+
* `setupFilesAfterEnv`. Jest loads no `.env` of its own in a worker either,
|
|
10
|
+
* hence `loadDotEnv` first.
|
|
11
|
+
*
|
|
12
|
+
* Nothing is handed over from `global-setup`: the URL is derived by the same
|
|
13
|
+
* pure function in both places, so there is no channel to get out of step.
|
|
14
|
+
*/
|
|
15
|
+
loadDotEnv();
|
|
16
|
+
|
|
17
|
+
process.env['DATABASE_URL'] = resolveTestDatabaseUrl();
|
|
18
|
+
|
|
19
|
+
// A root admin is provisioned on boot when these are set, which is what gives
|
|
20
|
+
// the suite a real account to sign in with.
|
|
21
|
+
process.env['ORTHA_ROOT_ADMIN_EMAIL'] ||= 'e2e@example.com';
|
|
22
|
+
process.env['ORTHA_ROOT_ADMIN_PASSWORD'] ||= 'e2e-password-not-a-secret';
|
|
23
|
+
|
|
24
|
+
// `createServer` builds its own Nest app and takes no logger option, so this
|
|
25
|
+
// is the only way to keep a full boot banner — every mapped route, every
|
|
26
|
+
// seeder — out of the output for each suite that boots one. A failing
|
|
27
|
+
// assertion is then the first thing on screen instead of the last.
|
|
28
|
+
Logger.overrideLogger(false);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { Pool } from 'pg';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loads `.env` into `process.env`.
|
|
6
|
+
*
|
|
7
|
+
* Jest loads nothing — not in the main process where `globalSetup` runs, and
|
|
8
|
+
* not in the workers. `ortha.config.ts` reads `process.env` at import time and
|
|
9
|
+
* refuses to load without `DATABASE_URL`, so every entry point into this suite
|
|
10
|
+
* has to call this **before** anything reaches the config.
|
|
11
|
+
*
|
|
12
|
+
* Values already exported in the shell win, which is what lets CI set the
|
|
13
|
+
* database without a file.
|
|
14
|
+
*/
|
|
15
|
+
export function loadDotEnv(): void {
|
|
16
|
+
if (existsSync('.env') && typeof process.loadEnvFile === 'function') {
|
|
17
|
+
process.loadEnvFile('.env');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The database the server e2e suite runs against.
|
|
23
|
+
*
|
|
24
|
+
* **Never your development database.** This suite truncates every table
|
|
25
|
+
* between tests, and `DATABASE_URL` is routinely set in a `.env` pointing at
|
|
26
|
+
* the database you are actually working in. So the URL is derived into a
|
|
27
|
+
* separate `<name>_e2e`, and `E2E_DATABASE_URL` overrides it — an opt-in name
|
|
28
|
+
* that cannot be triggered by accident.
|
|
29
|
+
*/
|
|
30
|
+
const SUFFIX = '_e2e';
|
|
31
|
+
|
|
32
|
+
export function resolveTestDatabaseUrl(): string {
|
|
33
|
+
const explicit = process.env['E2E_DATABASE_URL']?.trim();
|
|
34
|
+
if (explicit) return explicit;
|
|
35
|
+
|
|
36
|
+
const base = process.env['DATABASE_URL']?.trim();
|
|
37
|
+
if (!base) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'Neither E2E_DATABASE_URL nor DATABASE_URL is set — the e2e suite ' +
|
|
40
|
+
'needs to know which database to create and truncate.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const url = new URL(base);
|
|
45
|
+
const database = url.pathname.replace(/^\//, '');
|
|
46
|
+
|
|
47
|
+
// **Idempotent.** `global-setup` runs in Jest's main process and points
|
|
48
|
+
// `DATABASE_URL` at the test database; workers fork from it and inherit
|
|
49
|
+
// that value, so a naive append derives `app_e2e_e2e` in the worker and
|
|
50
|
+
// every test then fails against a database nobody created.
|
|
51
|
+
if (database.endsWith(SUFFIX)) return base;
|
|
52
|
+
|
|
53
|
+
url.pathname = `/${database}${SUFFIX}`;
|
|
54
|
+
|
|
55
|
+
return url.toString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Refuses to run against the database `DATABASE_URL` names.
|
|
60
|
+
*
|
|
61
|
+
* The derivation above makes a collision unlikely, but `E2E_DATABASE_URL` can
|
|
62
|
+
* be set to anything — and the first thing this suite does to whatever it is
|
|
63
|
+
* handed is delete every row. Check before the first `TRUNCATE`, not after.
|
|
64
|
+
*/
|
|
65
|
+
export function assertDisposable(testUrl: string): void {
|
|
66
|
+
const development = process.env['DATABASE_URL']?.trim();
|
|
67
|
+
|
|
68
|
+
if (development && new URL(testUrl).href === new URL(development).href) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'E2E_DATABASE_URL points at the same database as DATABASE_URL. ' +
|
|
71
|
+
'This suite truncates every table — point it somewhere disposable.'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Opens a pool against the `postgres` maintenance database on the same host. */
|
|
77
|
+
function maintenancePool(testUrl: string): Pool {
|
|
78
|
+
const url = new URL(testUrl);
|
|
79
|
+
const database = url.pathname.replace(/^\//, '');
|
|
80
|
+
url.pathname = '/postgres';
|
|
81
|
+
|
|
82
|
+
return Object.assign(new Pool({ connectionString: url.toString() }), {
|
|
83
|
+
targetDatabase: database
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Creates the test database if it is not there yet.
|
|
89
|
+
*
|
|
90
|
+
* `CREATE DATABASE` cannot run inside a transaction and has no `IF NOT
|
|
91
|
+
* EXISTS`, so this asks first. Losing the race with another runner is fine —
|
|
92
|
+
* `42P04` means someone else created it, which is the outcome we wanted.
|
|
93
|
+
*/
|
|
94
|
+
export async function ensureDatabase(testUrl: string): Promise<void> {
|
|
95
|
+
const pool = maintenancePool(testUrl);
|
|
96
|
+
const database = (pool as Pool & { targetDatabase: string })
|
|
97
|
+
.targetDatabase;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const { rowCount } = await pool.query(
|
|
101
|
+
'SELECT 1 FROM pg_database WHERE datname = $1',
|
|
102
|
+
[database]
|
|
103
|
+
);
|
|
104
|
+
if (rowCount === 0) {
|
|
105
|
+
// The name comes from our own derivation, not from user input, and
|
|
106
|
+
// CREATE DATABASE takes no parameters.
|
|
107
|
+
await pool.query(`CREATE DATABASE "${database}"`);
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if ((error as { code?: string }).code !== '42P04') throw error;
|
|
111
|
+
} finally {
|
|
112
|
+
await pool.end();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Empties every table, leaving the schema and the migration history alone.
|
|
118
|
+
*
|
|
119
|
+
* One `TRUNCATE … CASCADE` rather than a delete per table: it ignores foreign
|
|
120
|
+
* keys, so the suite never has to know which order its own content types
|
|
121
|
+
* depend on each other in. `RESTART IDENTITY` keeps sequences from drifting
|
|
122
|
+
* across a run, so an id asserted in one test means the same thing in the next.
|
|
123
|
+
*/
|
|
124
|
+
export async function resetDatabase(testUrl: string): Promise<void> {
|
|
125
|
+
const pool = new Pool({ connectionString: testUrl });
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const { rows } = await pool.query<{ tables: string[] }>(`
|
|
129
|
+
SELECT array_agg(format('%I.%I', schemaname, tablename)) AS tables
|
|
130
|
+
FROM pg_tables
|
|
131
|
+
WHERE schemaname = 'public'
|
|
132
|
+
AND tablename NOT LIKE '\\_\\_drizzle%'
|
|
133
|
+
`);
|
|
134
|
+
const tables = rows[0]?.tables;
|
|
135
|
+
if (!tables?.length) return;
|
|
136
|
+
|
|
137
|
+
await pool.query(
|
|
138
|
+
`TRUNCATE ${tables.join(', ')} RESTART IDENTITY CASCADE`
|
|
139
|
+
);
|
|
140
|
+
} finally {
|
|
141
|
+
await pool.end();
|
|
142
|
+
}
|
|
143
|
+
}
|