@decohere/data-typeorm 0.0.1-dev.125 → 0.0.1-dev.5
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/package.json +8 -24
- package/src/bootstrap-policy.ts +84 -0
- package/src/bootstrap.ts +76 -0
- package/src/data-source.factory.ts +51 -0
- package/src/database-options.ts +10 -0
- package/src/dialect-map.ts +12 -0
- package/{es6/src/inject-data-source.js → src/inject-data-source.ts} +15 -9
- package/src/scripts/user.ts +234 -0
- package/src/server-manager.ts +300 -0
- package/src/sql-pretty.transport.ts +29 -0
- package/src/type-orm.module.ts +155 -0
- package/src/type-orm.provider.ts +37 -0
- package/src/type-orm.repository.ts +33 -0
- package/src/typeorm-scribe.logger.ts +55 -0
- package/src/typeorm.extensions.ts +42 -0
- package/src/unit-of-work.ts +52 -0
- package/tests/bootstrap-schemas.docker.spec.ts +145 -0
- package/tests/bootstrap.spec.ts +156 -0
- package/tests/db-least-privilege.pentest.spec.ts +125 -0
- package/tests/postgres-container.ts +139 -0
- package/tests/prepare-database.docker.spec.ts +96 -0
- package/tests/server-manager.bootstrap.spec.ts +72 -0
- package/tests/trusted-dev-postgres.spec.ts +212 -0
- package/tests/type-orm.module.spec.ts +123 -0
- package/tests/typeorm.lifecycle.spec.ts +97 -0
- package/tests/unit-of-work.spec.ts +54 -0
- package/tests/user-scripts.pentest.spec.ts +42 -0
- package/LICENSE +0 -15
- package/commonjs/src/bootstrap-policy.js +0 -69
- package/commonjs/src/bootstrap.js +0 -142
- package/commonjs/src/data-source.factory.js +0 -42
- package/commonjs/src/database-options.js +0 -4
- package/commonjs/src/dialect-map.js +0 -26
- package/commonjs/src/index.js +0 -24
- package/commonjs/src/inject-data-source.js +0 -33
- package/commonjs/src/scripts/user.js +0 -259
- package/commonjs/src/server-manager.js +0 -294
- package/commonjs/src/sql-pretty.transport.js +0 -35
- package/commonjs/src/type-orm.module.js +0 -115
- package/commonjs/src/type-orm.provider.js +0 -36
- package/commonjs/src/type-orm.repository.js +0 -30
- package/commonjs/src/typeorm-scribe.logger.js +0 -70
- package/commonjs/src/typeorm.extensions.js +0 -60
- package/commonjs/src/unit-of-work.js +0 -49
- package/es6/src/bootstrap-policy.js +0 -55
- package/es6/src/bootstrap.js +0 -139
- package/es6/src/data-source.factory.js +0 -27
- package/es6/src/database-options.js +0 -1
- package/es6/src/dialect-map.js +0 -8
- package/es6/src/scripts/user.js +0 -209
- package/es6/src/server-manager.js +0 -284
- package/es6/src/sql-pretty.transport.js +0 -20
- package/es6/src/type-orm.module.js +0 -105
- package/es6/src/type-orm.provider.js +0 -26
- package/es6/src/type-orm.repository.js +0 -20
- package/es6/src/typeorm-scribe.logger.js +0 -60
- package/es6/src/typeorm.extensions.js +0 -50
- package/es6/src/unit-of-work.js +0 -39
- package/types/src/bootstrap-policy.d.ts +0 -19
- package/types/src/bootstrap.d.ts +0 -16
- package/types/src/data-source.factory.d.ts +0 -17
- package/types/src/database-options.d.ts +0 -9
- package/types/src/dialect-map.d.ts +0 -8
- package/types/src/index.d.ts +0 -7
- package/types/src/inject-data-source.d.ts +0 -14
- package/types/src/scripts/user.d.ts +0 -56
- package/types/src/server-manager.d.ts +0 -39
- package/types/src/sql-pretty.transport.d.ts +0 -2
- package/types/src/type-orm.module.d.ts +0 -17
- package/types/src/type-orm.provider.d.ts +0 -14
- package/types/src/type-orm.repository.d.ts +0 -12
- package/types/src/typeorm-scribe.logger.d.ts +0 -9
- package/types/src/typeorm.extensions.d.ts +0 -11
- package/types/src/unit-of-work.d.ts +0 -20
- /package/{es6/src/index.js → src/index.ts} +0 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ConnectionInfo } from "@decohere/data";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
4
|
+
import { PostgresServerManager } from "../src/server-manager.js";
|
|
5
|
+
import { type PostgresContainer, startPostgres } from "./postgres-container.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Real-Postgres gate for scaffold-shaped bootstrap (CU-869eja1c8).
|
|
9
|
+
* Catches bugs that SQL-shape / mocked-Pool tests miss (PASSWORD $1, missing maintenance DB).
|
|
10
|
+
* Needs Docker: runs under `pnpm test:docker`, not `pnpm test`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const BOOTSTRAP_USER = "decohere_bootstrap";
|
|
14
|
+
const BOOTSTRAP_PASSWORD = "boot'strap-secret!";
|
|
15
|
+
const APP_USER = "crypto_api_app";
|
|
16
|
+
const APP_PASSWORD = "app'secret!";
|
|
17
|
+
const MIGRATE_USER = "crypto_api_migrate";
|
|
18
|
+
const MIGRATE_PASSWORD = "mig'secret!";
|
|
19
|
+
const APP_DATABASE = "crypto_api_dev";
|
|
20
|
+
|
|
21
|
+
describe("prepareDatabase against real Postgres CU-869eja1c8", () => {
|
|
22
|
+
let postgres: PostgresContainer;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
postgres = await startPostgres();
|
|
26
|
+
await postgres.superuser.query(
|
|
27
|
+
`CREATE ROLE ${BOOTSTRAP_USER} LOGIN PASSWORD '${BOOTSTRAP_PASSWORD.replaceAll("'", "''")}' CREATEDB CREATEROLE NOSUPERUSER NOBYPASSRLS`,
|
|
28
|
+
);
|
|
29
|
+
await postgres.superuser.query(`GRANT CONNECT ON DATABASE postgres TO ${BOOTSTRAP_USER}`);
|
|
30
|
+
}, 120_000);
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await postgres?.stop();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function connectionInfo(overrides: Partial<ConnectionInfo> = {}): ConnectionInfo {
|
|
37
|
+
return {
|
|
38
|
+
name: "api",
|
|
39
|
+
provider: "postgres",
|
|
40
|
+
host: postgres.host,
|
|
41
|
+
port: postgres.port,
|
|
42
|
+
database: APP_DATABASE,
|
|
43
|
+
synchronize: true,
|
|
44
|
+
seed: false,
|
|
45
|
+
username: APP_USER,
|
|
46
|
+
password: APP_PASSWORD,
|
|
47
|
+
bootstrap: true,
|
|
48
|
+
adminUser: BOOTSTRAP_USER,
|
|
49
|
+
adminPassword: BOOTSTRAP_PASSWORD,
|
|
50
|
+
migrateUsername: MIGRATE_USER,
|
|
51
|
+
migratePassword: MIGRATE_PASSWORD,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it("connects as decohere_bootstrap, creates the app DB, and provisions app/migrate roles", async () => {
|
|
57
|
+
await using manager = new PostgresServerManager("crypto-it", connectionInfo());
|
|
58
|
+
await manager.connect();
|
|
59
|
+
await manager.initializeDatabase(APP_DATABASE);
|
|
60
|
+
await manager.provisionRoles({
|
|
61
|
+
database: APP_DATABASE,
|
|
62
|
+
appUsername: APP_USER,
|
|
63
|
+
appPassword: APP_PASSWORD,
|
|
64
|
+
migrateUsername: MIGRATE_USER,
|
|
65
|
+
migratePassword: MIGRATE_PASSWORD,
|
|
66
|
+
allowAppDdl: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const app = new Pool({
|
|
70
|
+
host: postgres.host,
|
|
71
|
+
port: postgres.port,
|
|
72
|
+
user: APP_USER,
|
|
73
|
+
password: APP_PASSWORD,
|
|
74
|
+
database: APP_DATABASE,
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
const result = await app.query("SELECT 1 AS ok");
|
|
78
|
+
expect(result.rows[0]?.ok).toBe(1);
|
|
79
|
+
} finally {
|
|
80
|
+
await app.end();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const migrate = new Pool({
|
|
84
|
+
host: postgres.host,
|
|
85
|
+
port: postgres.port,
|
|
86
|
+
user: MIGRATE_USER,
|
|
87
|
+
password: MIGRATE_PASSWORD,
|
|
88
|
+
database: APP_DATABASE,
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
await migrate.query("CREATE TABLE IF NOT EXISTS it_probe (id int)");
|
|
92
|
+
} finally {
|
|
93
|
+
await migrate.end();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ConnectionInfo } from "@decohere/data";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const { PoolMock, connectMock } = vi.hoisted(() => {
|
|
5
|
+
const connectMock = vi.fn();
|
|
6
|
+
const PoolMock = vi.fn(function Pool(
|
|
7
|
+
this: { connect: typeof connectMock },
|
|
8
|
+
_options: unknown,
|
|
9
|
+
) {
|
|
10
|
+
this.connect = connectMock;
|
|
11
|
+
return this;
|
|
12
|
+
});
|
|
13
|
+
return { PoolMock, connectMock };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock("pg", () => ({ Pool: PoolMock }));
|
|
17
|
+
|
|
18
|
+
import { PostgresServerManager } from "../src/server-manager.js";
|
|
19
|
+
|
|
20
|
+
function adminInfo(overrides: Partial<ConnectionInfo> = {}): ConnectionInfo {
|
|
21
|
+
return {
|
|
22
|
+
name: "api",
|
|
23
|
+
provider: "postgres",
|
|
24
|
+
host: "postgres",
|
|
25
|
+
port: 5432,
|
|
26
|
+
database: "crypto_api_dev",
|
|
27
|
+
synchronize: true,
|
|
28
|
+
seed: true,
|
|
29
|
+
username: "crypto_api_app",
|
|
30
|
+
password: "app-secret",
|
|
31
|
+
bootstrap: true,
|
|
32
|
+
adminUser: "decohere_bootstrap",
|
|
33
|
+
adminPassword: "bootstrap-secret",
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("PostgresServerManager.connect CU-869ej9yq1", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
PoolMock.mockClear();
|
|
41
|
+
connectMock.mockReset();
|
|
42
|
+
connectMock.mockResolvedValue({ release: vi.fn() });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("connects the bootstrap role to the postgres maintenance database", async () => {
|
|
46
|
+
const manager = new PostgresServerManager("crypto", adminInfo());
|
|
47
|
+
await manager.connect();
|
|
48
|
+
|
|
49
|
+
expect(PoolMock).toHaveBeenCalledWith(
|
|
50
|
+
expect.objectContaining({
|
|
51
|
+
user: "decohere_bootstrap",
|
|
52
|
+
password: "bootstrap-secret",
|
|
53
|
+
host: "postgres",
|
|
54
|
+
port: 5432,
|
|
55
|
+
database: "postgres",
|
|
56
|
+
application_name: "crypto",
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
expect(PoolMock.mock.calls[0]?.[0]).not.toEqual(
|
|
60
|
+
expect.objectContaining({ database: "decohere_bootstrap" }),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("wraps connection failures with host/port context", async () => {
|
|
65
|
+
connectMock.mockRejectedValue(new Error('database "decohere_bootstrap" does not exist'));
|
|
66
|
+
const manager = new PostgresServerManager("crypto", adminInfo());
|
|
67
|
+
|
|
68
|
+
await expect(manager.connect()).rejects.toThrow(
|
|
69
|
+
/Cannot connect to PostgreSQL at postgres:5432/,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { ConnectionInfo } from "@decohere/data";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const { PoolMock, connectMock, queryMock } = vi.hoisted(() => {
|
|
5
|
+
const connectMock = vi.fn();
|
|
6
|
+
const queryMock = vi.fn();
|
|
7
|
+
const PoolMock = vi.fn(function Pool(
|
|
8
|
+
this: { connect: typeof connectMock; query: typeof queryMock; end: () => Promise<void> },
|
|
9
|
+
_options: unknown,
|
|
10
|
+
) {
|
|
11
|
+
this.connect = connectMock;
|
|
12
|
+
this.query = queryMock;
|
|
13
|
+
this.end = async () => {};
|
|
14
|
+
return this;
|
|
15
|
+
});
|
|
16
|
+
return { PoolMock, connectMock, queryMock };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
vi.mock("pg", () => ({ Pool: PoolMock }));
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
assertBootstrapCredentials,
|
|
23
|
+
assertDataSourceAuth,
|
|
24
|
+
shouldBootstrapDatabase,
|
|
25
|
+
} from "../src/bootstrap-policy.js";
|
|
26
|
+
import { createUserQuery } from "../src/scripts/user.js";
|
|
27
|
+
import { PostgresServerManager } from "../src/server-manager.js";
|
|
28
|
+
|
|
29
|
+
// A local dev Postgres that trusts every connection has no passwords at all (#578), and says so
|
|
30
|
+
// with auth: trust (#656).
|
|
31
|
+
function trustedInfo(overrides: Partial<ConnectionInfo> = {}): ConnectionInfo {
|
|
32
|
+
return {
|
|
33
|
+
name: "api",
|
|
34
|
+
provider: "postgres",
|
|
35
|
+
host: "postgres",
|
|
36
|
+
port: 5432,
|
|
37
|
+
database: "crypto_api_dev",
|
|
38
|
+
synchronize: true,
|
|
39
|
+
seed: true,
|
|
40
|
+
username: "crypto_api_app",
|
|
41
|
+
auth: "trust",
|
|
42
|
+
migrateUsername: "crypto_api_migrate",
|
|
43
|
+
adminUser: "decohere_bootstrap",
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sqlSent(): string[] {
|
|
49
|
+
return queryMock.mock.calls.map((call) => String(call[0]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("trust auth is declared, never guessed (#656)", () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
PoolMock.mockClear();
|
|
55
|
+
connectMock.mockReset();
|
|
56
|
+
connectMock.mockResolvedValue({ release: vi.fn() });
|
|
57
|
+
queryMock.mockReset();
|
|
58
|
+
queryMock.mockImplementation(async (sql: string) =>
|
|
59
|
+
sql.includes("datdba")
|
|
60
|
+
? { rowCount: 1, rows: [{ owner: "decohere_bootstrap" }] }
|
|
61
|
+
: { rowCount: 0, rows: [] },
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("checking a data source's auth", () => {
|
|
66
|
+
it("accepts auth: trust with no passwords in development", () => {
|
|
67
|
+
expect(() => assertDataSourceAuth(trustedInfo(), "development")).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("refuses auth: trust outside development", () => {
|
|
71
|
+
expect(() => assertDataSourceAuth(trustedInfo(), "production")).toThrow(
|
|
72
|
+
/Data source "api" sets auth: trust, which is allowed only in development/,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("refuses a missing password without auth: trust, in development too", () => {
|
|
77
|
+
for (const env of ["development", "production"]) {
|
|
78
|
+
expect(() => assertDataSourceAuth(trustedInfo({ auth: undefined }), env)).toThrow(
|
|
79
|
+
/Data source "api" has no password\. Set password, or auth: trust/,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("treats an empty password as missing", () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
assertDataSourceAuth(trustedInfo({ auth: "password", password: "" }), "development"),
|
|
87
|
+
).toThrow(/has no password/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("accepts a password without auth, in any environment", () => {
|
|
91
|
+
expect(() =>
|
|
92
|
+
assertDataSourceAuth(
|
|
93
|
+
trustedInfo({ auth: undefined, password: "s3cret" }),
|
|
94
|
+
"production",
|
|
95
|
+
),
|
|
96
|
+
).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("refuses an auth it does not know", () => {
|
|
100
|
+
expect(() =>
|
|
101
|
+
assertDataSourceAuth(
|
|
102
|
+
trustedInfo({ auth: "md5" as ConnectionInfo["auth"] }),
|
|
103
|
+
"development",
|
|
104
|
+
),
|
|
105
|
+
).toThrow(/auth must be "password" or "trust" \(got "md5"\)/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("refuses a migrate role with no password when bootstrapping without auth: trust", () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
assertBootstrapCredentials(trustedInfo({ auth: undefined, password: "s3cret" })),
|
|
111
|
+
).toThrow(/Data source "api" has migrateUsername but no migratePassword/);
|
|
112
|
+
expect(() => assertBootstrapCredentials(trustedInfo())).not.toThrow();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("creating a role", () => {
|
|
117
|
+
it("leaves PASSWORD out when no password is given", () => {
|
|
118
|
+
const { text } = createUserQuery("crypto_api_app");
|
|
119
|
+
expect(text).toBe(
|
|
120
|
+
'CREATE USER "crypto_api_app" WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE',
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("still refuses an empty password, which is a typo and not a choice", () => {
|
|
125
|
+
expect(() => createUserQuery("crypto_api_app", "")).toThrow(/Invalid password/);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("deciding to bootstrap", () => {
|
|
130
|
+
it("bootstraps in development with auth: trust and no admin password", () => {
|
|
131
|
+
expect(shouldBootstrapDatabase(trustedInfo(), "development")).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("no longer lets development alone stand in for an admin password", () => {
|
|
135
|
+
expect(shouldBootstrapDatabase(trustedInfo({ auth: undefined }), "development")).toBe(
|
|
136
|
+
false,
|
|
137
|
+
);
|
|
138
|
+
expect(() =>
|
|
139
|
+
shouldBootstrapDatabase(
|
|
140
|
+
trustedInfo({ auth: undefined, bootstrap: true }),
|
|
141
|
+
"development",
|
|
142
|
+
),
|
|
143
|
+
).toThrow(/adminPassword may be left out only with auth: trust/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("still needs an admin user", () => {
|
|
147
|
+
expect(
|
|
148
|
+
shouldBootstrapDatabase(trustedInfo({ adminUser: undefined }), "development"),
|
|
149
|
+
).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("connecting as the bootstrap role", () => {
|
|
154
|
+
it("connects with no password when the server is trusted", async () => {
|
|
155
|
+
const manager = new PostgresServerManager("crypto", trustedInfo());
|
|
156
|
+
await manager.connect();
|
|
157
|
+
|
|
158
|
+
const options = PoolMock.mock.calls[0]?.[0] as { user: string; password?: string };
|
|
159
|
+
expect(options.user).toBe("decohere_bootstrap");
|
|
160
|
+
expect(options.password).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("asks for the admin password when the server is not trusted", async () => {
|
|
164
|
+
const manager = new PostgresServerManager("crypto", trustedInfo({ auth: undefined }));
|
|
165
|
+
await expect(manager.connect()).rejects.toThrow(/adminUser and adminPassword/);
|
|
166
|
+
expect(PoolMock).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("provisioning the app and migrate roles", () => {
|
|
171
|
+
it("creates both without a PASSWORD clause when the server is trusted", async () => {
|
|
172
|
+
const manager = new PostgresServerManager("crypto", trustedInfo());
|
|
173
|
+
await manager.provisionRoles({
|
|
174
|
+
database: "crypto_api_dev",
|
|
175
|
+
appUsername: "crypto_api_app",
|
|
176
|
+
migrateUsername: "crypto_api_migrate",
|
|
177
|
+
allowAppDdl: false,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const creates = sqlSent().filter((sql) => sql.startsWith("CREATE USER"));
|
|
181
|
+
expect(creates).toHaveLength(2);
|
|
182
|
+
for (const sql of creates) {
|
|
183
|
+
expect(sql).not.toContain("PASSWORD");
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("still sets a password on a role that was given one", async () => {
|
|
188
|
+
const manager = new PostgresServerManager("crypto", trustedInfo());
|
|
189
|
+
await manager.provisionRoles({
|
|
190
|
+
database: "crypto_api_dev",
|
|
191
|
+
appUsername: "crypto_api_app",
|
|
192
|
+
appPassword: "app-secret",
|
|
193
|
+
allowAppDdl: false,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const create = sqlSent().find((sql) => sql.startsWith("CREATE USER"));
|
|
197
|
+
expect(create).toContain("PASSWORD 'app-secret'");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("refuses a role with no password when the server is not trusted", async () => {
|
|
201
|
+
const manager = new PostgresServerManager("crypto", trustedInfo({ auth: undefined }));
|
|
202
|
+
await expect(
|
|
203
|
+
manager.provisionRoles({
|
|
204
|
+
database: "crypto_api_prod",
|
|
205
|
+
appUsername: "crypto_api_app",
|
|
206
|
+
allowAppDdl: false,
|
|
207
|
+
}),
|
|
208
|
+
).rejects.toThrow(/password is required for database role "crypto_api_app"/i);
|
|
209
|
+
expect(sqlSent().some((sql) => sql.startsWith("CREATE USER"))).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { type ConnectionInfo, DatabaseConfig, DEFAULT_DATA_SOURCE } from "@decohere/data";
|
|
3
|
+
import { ServiceContainerBuilder, ResolveError } from "@decohere/di";
|
|
4
|
+
import { injectable } from "@decohere/di/decorators";
|
|
5
|
+
import type { DataSource } from "typeorm";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { DataSourceFactory } from "../src/data-source.factory.js";
|
|
8
|
+
import {
|
|
9
|
+
injectDataSource,
|
|
10
|
+
TypeOrmDataSourceToken,
|
|
11
|
+
typeOrmDataSourceToken,
|
|
12
|
+
} from "../src/inject-data-source.js";
|
|
13
|
+
import { TypeOrmModule } from "../src/type-orm.module.js";
|
|
14
|
+
import { TypeOrmUnitOfWork } from "../src/unit-of-work.js";
|
|
15
|
+
|
|
16
|
+
class PositionEntity {}
|
|
17
|
+
|
|
18
|
+
function pgInfo(overrides: Partial<ConnectionInfo> = {}): ConnectionInfo {
|
|
19
|
+
return {
|
|
20
|
+
name: "default",
|
|
21
|
+
provider: "postgres",
|
|
22
|
+
host: "localhost",
|
|
23
|
+
port: 5432,
|
|
24
|
+
database: "app",
|
|
25
|
+
synchronize: false,
|
|
26
|
+
seed: false,
|
|
27
|
+
username: "app",
|
|
28
|
+
password: "secret",
|
|
29
|
+
bootstrap: false,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fakeDataSource() {
|
|
35
|
+
const repository = { find: vi.fn().mockResolvedValue([]) };
|
|
36
|
+
return {
|
|
37
|
+
initialize: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
getRepository: vi.fn(() => repository),
|
|
39
|
+
query: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
|
40
|
+
createQueryRunner: vi.fn(() => ({
|
|
41
|
+
manager: {},
|
|
42
|
+
isReleased: false,
|
|
43
|
+
release() {},
|
|
44
|
+
})),
|
|
45
|
+
repository,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("TypeOrmModule DataSource singleton CU-869ekhrex", () => {
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("lets a singleton adapter inject DataSource and call getRepository", async () => {
|
|
55
|
+
const ds = fakeDataSource();
|
|
56
|
+
vi.spyOn(DataSourceFactory, "create").mockReturnValue(ds as unknown as DataSource);
|
|
57
|
+
|
|
58
|
+
@injectable()
|
|
59
|
+
class TypeOrmPositionBook {
|
|
60
|
+
public constructor(@injectDataSource() private readonly dataSource: DataSource) {}
|
|
61
|
+
public list() {
|
|
62
|
+
return this.dataSource.getRepository(PositionEntity).find();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const containerBuilder = new ServiceContainerBuilder();
|
|
67
|
+
containerBuilder.registerSingleton(TypeOrmPositionBook);
|
|
68
|
+
|
|
69
|
+
const databaseConfig = new DatabaseConfig();
|
|
70
|
+
databaseConfig.dataSources = [pgInfo()];
|
|
71
|
+
await new TypeOrmModule().configure({
|
|
72
|
+
applicationName: "funding-fee.api",
|
|
73
|
+
databaseConfig,
|
|
74
|
+
moduleConfigurations: [{ entities: [PositionEntity] }],
|
|
75
|
+
containerBuilder,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const container = containerBuilder.build();
|
|
79
|
+
const book = container.resolve(TypeOrmPositionBook);
|
|
80
|
+
await book.list();
|
|
81
|
+
|
|
82
|
+
expect(ds.getRepository).toHaveBeenCalledWith(PositionEntity);
|
|
83
|
+
expect(ds.repository.find).toHaveBeenCalled();
|
|
84
|
+
expect(container.resolve(TypeOrmDataSourceToken)).toBe(ds);
|
|
85
|
+
expect(container.resolve(TypeOrmDataSourceToken)).toBe(
|
|
86
|
+
container.resolve(TypeOrmDataSourceToken),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("registers a named DataSource without replacing request-scoped UoW", async () => {
|
|
91
|
+
const ds = fakeDataSource();
|
|
92
|
+
vi.spyOn(DataSourceFactory, "create").mockReturnValue(ds as unknown as DataSource);
|
|
93
|
+
|
|
94
|
+
@injectable()
|
|
95
|
+
class TypeOrmPositionBook {
|
|
96
|
+
public constructor(@injectDataSource("api") private readonly dataSource: DataSource) {}
|
|
97
|
+
public source() {
|
|
98
|
+
return this.dataSource;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const containerBuilder = new ServiceContainerBuilder();
|
|
103
|
+
containerBuilder.registerSingleton(TypeOrmPositionBook);
|
|
104
|
+
|
|
105
|
+
const databaseConfig = new DatabaseConfig();
|
|
106
|
+
databaseConfig.dataSources = [pgInfo({ name: "api" })];
|
|
107
|
+
await new TypeOrmModule().configure({
|
|
108
|
+
applicationName: "funding-fee.api",
|
|
109
|
+
databaseConfig,
|
|
110
|
+
moduleConfigurations: [{ name: "api", entities: [PositionEntity] }],
|
|
111
|
+
containerBuilder,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const container = containerBuilder.build();
|
|
115
|
+
expect(container.resolve(TypeOrmPositionBook).source()).toBe(ds);
|
|
116
|
+
expect(container.resolve(typeOrmDataSourceToken("api"))).toBe(ds);
|
|
117
|
+
expect(() => container.resolve(TypeOrmDataSourceToken)).toThrow(ResolveError);
|
|
118
|
+
|
|
119
|
+
using scope = container.beginScope("req");
|
|
120
|
+
expect(scope.resolve("api")).toBeInstanceOf(TypeOrmUnitOfWork);
|
|
121
|
+
expect(() => scope.resolve(DEFAULT_DATA_SOURCE)).toThrow(ResolveError);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { type ConnectionInfo, DatabaseConfig } from "@decohere/data";
|
|
3
|
+
import { Builder, type HostedService } from "@decohere/http";
|
|
4
|
+
import { injectable } from "@decohere/di/decorators";
|
|
5
|
+
import {
|
|
6
|
+
ConfigurationServiceToken,
|
|
7
|
+
type IConfigurationService,
|
|
8
|
+
} from "@decohere/core/configuration";
|
|
9
|
+
import type { Constructable } from "@decohere/core/reflection";
|
|
10
|
+
import { DataSource } from "typeorm";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import "../src/typeorm.extensions.js";
|
|
13
|
+
|
|
14
|
+
const connection: ConnectionInfo = {
|
|
15
|
+
name: "default",
|
|
16
|
+
provider: "postgres",
|
|
17
|
+
host: "localhost",
|
|
18
|
+
port: 5432,
|
|
19
|
+
database: "app",
|
|
20
|
+
synchronize: false,
|
|
21
|
+
seed: false,
|
|
22
|
+
username: "app",
|
|
23
|
+
password: "secret",
|
|
24
|
+
bootstrap: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
class StaticConfig implements IConfigurationService {
|
|
28
|
+
public readonly dataSources = [];
|
|
29
|
+
public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
|
|
30
|
+
if (type === DatabaseConfig) {
|
|
31
|
+
const config = new DatabaseConfig();
|
|
32
|
+
config.dataSources = [connection];
|
|
33
|
+
return config as unknown as T;
|
|
34
|
+
}
|
|
35
|
+
return {} as T;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let events: string[] = [];
|
|
40
|
+
|
|
41
|
+
@injectable()
|
|
42
|
+
class UsesTheDatabase implements HostedService {
|
|
43
|
+
public start() {}
|
|
44
|
+
public stop() {
|
|
45
|
+
events.push("hosted service stopped");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The real DataSource class, with connecting and disconnecting stubbed: no database needed. */
|
|
50
|
+
function stubConnections() {
|
|
51
|
+
vi.spyOn(DataSource.prototype, "initialize").mockImplementation(async function (
|
|
52
|
+
this: DataSource,
|
|
53
|
+
) {
|
|
54
|
+
Object.assign(this, { isInitialized: true });
|
|
55
|
+
return this;
|
|
56
|
+
});
|
|
57
|
+
return vi.spyOn(DataSource.prototype, "destroy").mockImplementation(async function (
|
|
58
|
+
this: DataSource,
|
|
59
|
+
) {
|
|
60
|
+
events.push("data source destroyed");
|
|
61
|
+
Object.assign(this, { isInitialized: false });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("withTypeOrm lifecycle (#541)", () => {
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.restoreAllMocks();
|
|
68
|
+
events = [];
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("destroys the DataSource when the app stops, after hosted services have stopped", async () => {
|
|
72
|
+
const destroy = stubConnections();
|
|
73
|
+
const app = await Builder.create()
|
|
74
|
+
.withServices((c) => c.registerSingleton(ConfigurationServiceToken, new StaticConfig()))
|
|
75
|
+
.withTypeOrm({ entities: [] })
|
|
76
|
+
.withHostedService(UsesTheDatabase)
|
|
77
|
+
.build();
|
|
78
|
+
await app.start({ port: 0, shutdownSignals: false });
|
|
79
|
+
|
|
80
|
+
await app.dispose();
|
|
81
|
+
|
|
82
|
+
expect(destroy).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(events).toEqual(["hosted service stopped", "data source destroyed"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("destroys a DataSource nothing resolved (no request ever used it)", async () => {
|
|
87
|
+
const destroy = stubConnections();
|
|
88
|
+
const app = await Builder.create()
|
|
89
|
+
.withServices((c) => c.registerSingleton(ConfigurationServiceToken, new StaticConfig()))
|
|
90
|
+
.withTypeOrm({ entities: [] })
|
|
91
|
+
.build();
|
|
92
|
+
|
|
93
|
+
await app.dispose();
|
|
94
|
+
|
|
95
|
+
expect(destroy).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { DataSource, QueryRunner } from "typeorm";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TypeOrmUnitOfWork } from "../src/unit-of-work.js";
|
|
4
|
+
|
|
5
|
+
/** A stand-in QueryRunner that records what happens to the connection. */
|
|
6
|
+
function fakeRunner(transactionActive: boolean) {
|
|
7
|
+
const events: string[] = [];
|
|
8
|
+
const runner = {
|
|
9
|
+
isTransactionActive: transactionActive,
|
|
10
|
+
isReleased: false,
|
|
11
|
+
async rollbackTransaction() {
|
|
12
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
13
|
+
events.push("rollback");
|
|
14
|
+
runner.isTransactionActive = false;
|
|
15
|
+
},
|
|
16
|
+
async release() {
|
|
17
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
18
|
+
events.push("release");
|
|
19
|
+
runner.isReleased = true;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const dataSource = { createQueryRunner: () => runner as unknown as QueryRunner };
|
|
23
|
+
return { uow: new TypeOrmUnitOfWork(dataSource as unknown as DataSource), runner, events };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// #502: a request that failed mid-transaction used to hand its connection back to the pool
|
|
27
|
+
// with the transaction still open, and without waiting for the release to finish.
|
|
28
|
+
describe("TypeOrmUnitOfWork disposal", () => {
|
|
29
|
+
it("rolls back an unfinished transaction, then releases the connection, and waits for both", async () => {
|
|
30
|
+
const { uow, runner, events } = fakeRunner(true);
|
|
31
|
+
|
|
32
|
+
await uow[Symbol.asyncDispose]();
|
|
33
|
+
|
|
34
|
+
expect(events).toEqual(["rollback", "release"]);
|
|
35
|
+
expect(runner.isReleased).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("only releases when no transaction is open", async () => {
|
|
39
|
+
const { uow, events } = fakeRunner(false);
|
|
40
|
+
|
|
41
|
+
await uow[Symbol.asyncDispose]();
|
|
42
|
+
|
|
43
|
+
expect(events).toEqual(["release"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does nothing when already released", async () => {
|
|
47
|
+
const { uow, runner, events } = fakeRunner(false);
|
|
48
|
+
runner.isReleased = true;
|
|
49
|
+
|
|
50
|
+
await uow[Symbol.asyncDispose]();
|
|
51
|
+
|
|
52
|
+
expect(events).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
assertSafeSqlIdent,
|
|
4
|
+
createUserQuery,
|
|
5
|
+
quoteIdent,
|
|
6
|
+
quoteLiteral,
|
|
7
|
+
userScripts,
|
|
8
|
+
} from "../src/scripts/user.js";
|
|
9
|
+
|
|
10
|
+
describe("pen-test — data-typeorm bootstrap SQL injection", () => {
|
|
11
|
+
it("rejects username that would break out of SQL string literals", () => {
|
|
12
|
+
expect(() => userScripts.createOwnerUser("evil'; DROP ROLE x; --", "pw", "db")).toThrow(
|
|
13
|
+
/Invalid username/,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("escapes password metacharacters in CREATE USER (Postgres forbids PASSWORD $1)", () => {
|
|
18
|
+
const create = createUserQuery("apiuser", "x'; DROP ROLE x; --");
|
|
19
|
+
expect(create.text).not.toMatch(/PASSWORD\s+\$\d/);
|
|
20
|
+
expect(create.values).toEqual([]);
|
|
21
|
+
expect(create.text).toContain(quoteLiteral("x'; DROP ROLE x; --"));
|
|
22
|
+
expect(create.text).toContain("''"); // doubled quote from escaping
|
|
23
|
+
// Breakout must stay inside the password literal, not become SQL.
|
|
24
|
+
expect(create.text).toMatch(/PASSWORD 'x''; DROP ROLE x; --'/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects database name with SQL metacharacters", () => {
|
|
28
|
+
expect(() =>
|
|
29
|
+
userScripts.createOwnerUser("apiuser", "secret", "db'; DROP DATABASE x; --"),
|
|
30
|
+
).toThrow(/Invalid database/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("emits CREATE USER with an escaped password literal for a safe username", () => {
|
|
34
|
+
const { text, values } = userScripts.createOwnerUser("api_user", "s3cret!", "webapi_dev");
|
|
35
|
+
expect(text).toContain("PASSWORD 's3cret!'");
|
|
36
|
+
expect(text).not.toMatch(/PASSWORD\s+\$\d/);
|
|
37
|
+
expect(values).toEqual([]);
|
|
38
|
+
expect(quoteIdent("api_user")).toBe('"api_user"');
|
|
39
|
+
expect(quoteLiteral("a'b")).toBe("'a''b'");
|
|
40
|
+
expect(() => assertSafeSqlIdent('x"; DROP', "username")).toThrow(/Invalid username/);
|
|
41
|
+
});
|
|
42
|
+
});
|