@galadrim/galadmin 0.1.236 → 0.2.6
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/.github/workflows/main.yml +33 -2
- package/README.md +65 -0
- package/dist/api.d.ts +16 -0
- package/dist/api.js +240 -189
- package/dist/api.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/dbManager.d.ts +5 -0
- package/dist/utils/dbManager.js +146 -11
- package/dist/utils/dbManager.js.map +1 -1
- package/dist/utils/imageDimensions.d.ts +5 -0
- package/dist/utils/imageDimensions.js +98 -0
- package/dist/utils/imageDimensions.js.map +1 -0
- package/jest.config.js +43 -0
- package/jest.integration.config.js +16 -0
- package/package.json +30 -10
- package/src/api.ts +284 -217
- package/src/front/src/App.test.js +28 -0
- package/src/front/src/components/OutsideClickHandler.test.js +53 -0
- package/src/front/src/components/RedirectToFirstView.test.js +38 -0
- package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
- package/src/front/src/utils/evalExpression.test.js +29 -0
- package/src/index.ts +1 -1
- package/src/utils/dbManager.ts +243 -102
- package/src/utils/imageDimensions.ts +112 -0
- package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
- package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
- package/tests/__mocks__/@google-cloud/storage.js +7 -0
- package/tests/__mocks__/argon2.js +3 -0
- package/tests/__mocks__/axios.js +7 -0
- package/tests/__mocks__/connect-pg-simple.js +24 -0
- package/tests/__mocks__/express-mysql-session.js +24 -0
- package/tests/__mocks__/mysql2/promise.js +3 -0
- package/tests/__mocks__/pg.js +3 -0
- package/tests/__mocks__/rollup.js +5 -0
- package/tests/__mocks__/ssh2-sftp-client.js +10 -0
- package/tests/api-helpers.test.ts +376 -0
- package/tests/dbManager.init.test.ts +129 -0
- package/tests/dbManager.test.ts +265 -0
- package/tests/edge-cases.test.ts +167 -0
- package/tests/helpers/app.ts +69 -0
- package/tests/helpers/fakeDb.ts +66 -0
- package/tests/helpers/fakePool.ts +76 -0
- package/tests/helpers/fixtures.ts +68 -0
- package/tests/imageDimensions.test.ts +111 -0
- package/tests/index.sessionStore.test.ts +19 -0
- package/tests/index.test.ts +363 -0
- package/tests/integration/__mocks__/axios.js +3 -0
- package/tests/integration/dbHarness.ts +381 -0
- package/tests/integration/dbManager.integration.test.ts +69 -0
- package/tests/integration/globalSetup.ts +43 -0
- package/tests/integration/globalTeardown.ts +42 -0
- package/tests/integration/routes.authorization.integration.test.ts +319 -0
- package/tests/integration/routes.config.integration.test.ts +189 -0
- package/tests/integration/routes.crud.integration.test.ts +805 -0
- package/tests/integration/routes.custom.integration.test.ts +376 -0
- package/tests/integration/routes.foreign.integration.test.ts +306 -0
- package/tests/integration/routes.importExport.integration.test.ts +404 -0
- package/tests/integration/routes.integration.test.ts +366 -0
- package/tests/integration/routes.postgresql.integration.test.ts +88 -0
- package/tests/integration/routes.records.integration.test.ts +634 -0
- package/tests/integration/routes.upload.integration.test.ts +425 -0
- package/tests/setup/silence-console.ts +15 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config + database-schema fixtures shared by the route tests.
|
|
3
|
+
*
|
|
4
|
+
* `enrichConfig` queries information_schema on every request; `schemaRows`
|
|
5
|
+
* factories feed the FakeDb so enrichment behaves like a real database.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function schemaRow(
|
|
9
|
+
table: string,
|
|
10
|
+
column: string,
|
|
11
|
+
opts: {
|
|
12
|
+
primary?: boolean;
|
|
13
|
+
nullable?: boolean;
|
|
14
|
+
dataType?: string;
|
|
15
|
+
columnType?: string;
|
|
16
|
+
} = {},
|
|
17
|
+
) {
|
|
18
|
+
return {
|
|
19
|
+
TABLE_NAME: table,
|
|
20
|
+
COLUMN_NAME: column,
|
|
21
|
+
COLUMN_KEY: opts.primary ? "PRI" : "",
|
|
22
|
+
IS_NULLABLE: opts.nullable ? "YES" : "NO",
|
|
23
|
+
DATA_TYPE: opts.dataType || "varchar",
|
|
24
|
+
COLUMN_TYPE: opts.columnType || opts.dataType || "varchar",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function fruitSchema(extra: any[] = []) {
|
|
29
|
+
return [
|
|
30
|
+
schemaRow("fruits", "id", { primary: true, dataType: "int" }),
|
|
31
|
+
schemaRow("fruits", "fruitName"),
|
|
32
|
+
schemaRow("fruits", "acidity", { dataType: "int", nullable: true }),
|
|
33
|
+
...extra,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A minimal table view: two plain columns, PK resolved by enrichment. */
|
|
38
|
+
export function fruitView(overrides: any = {}) {
|
|
39
|
+
return {
|
|
40
|
+
type: "table",
|
|
41
|
+
label: "Fruits",
|
|
42
|
+
tableName: "fruits",
|
|
43
|
+
columns: [
|
|
44
|
+
{ label: "Nom", name: "fruitName" },
|
|
45
|
+
{ label: "Acidité", name: "acidity" },
|
|
46
|
+
],
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function baseConfig(overrides: any = {}) {
|
|
52
|
+
return {
|
|
53
|
+
name: "Test project",
|
|
54
|
+
sessionSecret: "test-secret",
|
|
55
|
+
auth: { username: "admin", password: "admin-pass" },
|
|
56
|
+
mysql: {
|
|
57
|
+
host: "localhost",
|
|
58
|
+
user: "root",
|
|
59
|
+
password: "",
|
|
60
|
+
database: "galadmin_test",
|
|
61
|
+
},
|
|
62
|
+
views: [fruitView()],
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const EDITOR = { role: "editor", userId: 2 };
|
|
68
|
+
export const VIEWER = { role: "viewer", userId: 3 };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { getImageDimensions } from "../src/utils/imageDimensions";
|
|
5
|
+
|
|
6
|
+
const PNG_HEADER = Buffer.from(
|
|
7
|
+
"89504e470d0a1a0a0000000d49484452000000010000000108" + "0".repeat(64),
|
|
8
|
+
"hex",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const GIF_HEADER = Buffer.concat([
|
|
12
|
+
Buffer.from("GIF89a", "latin1"),
|
|
13
|
+
Buffer.from([0x64, 0x00, 0x32, 0x00]), // 100x50 little-endian
|
|
14
|
+
Buffer.alloc(8),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// SOI + SOF0 segment (height 50, width 100) + EOI
|
|
18
|
+
const JPEG_HEADER = Buffer.from("ffd8ffc0001108003200640301110002110003ffd9", "hex");
|
|
19
|
+
|
|
20
|
+
async function withTempFile(contents: Buffer | string): Promise<string> {
|
|
21
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "galadmin-imgdim-"));
|
|
22
|
+
const filePath = path.join(dir, "file");
|
|
23
|
+
fs.writeFileSync(filePath, contents);
|
|
24
|
+
return filePath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("getImageDimensions", () => {
|
|
28
|
+
it("parses a PNG IHDR", async () => {
|
|
29
|
+
const filePath = await withTempFile(PNG_HEADER);
|
|
30
|
+
expect(await getImageDimensions(filePath)).toEqual({ width: 1, height: 1 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("parses a GIF logical screen descriptor", async () => {
|
|
34
|
+
const filePath = await withTempFile(GIF_HEADER);
|
|
35
|
+
expect(await getImageDimensions(filePath)).toEqual({ width: 100, height: 50 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("parses a JPEG SOF0 frame header", async () => {
|
|
39
|
+
const filePath = await withTempFile(JPEG_HEADER);
|
|
40
|
+
expect(await getImageDimensions(filePath)).toEqual({ width: 100, height: 50 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null for a non-image payload even with an image extension (crafted upload)", async () => {
|
|
44
|
+
const filePath = await withTempFile(Buffer.from("craftedi 籍content-not-an-image"));
|
|
45
|
+
expect(await getImageDimensions(filePath)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns null for a truncated PNG", async () => {
|
|
49
|
+
const filePath = await withTempFile(PNG_SIGNATURE_ONLY());
|
|
50
|
+
expect(await getImageDimensions(filePath)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null when the file cannot be read", async () => {
|
|
54
|
+
expect(await getImageDimensions(path.join(os.tmpdir(), "does-not-exist-random-1234"))).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null for a PNG with zero dimensions", async () => {
|
|
58
|
+
const zero = Buffer.from(
|
|
59
|
+
"89504e470d0a1a0a0000000d49484452000000000000000008" + "0".repeat(64),
|
|
60
|
+
"hex",
|
|
61
|
+
);
|
|
62
|
+
const filePath = await withTempFile(zero);
|
|
63
|
+
expect(await getImageDimensions(filePath)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns null when the PNG IHDR chunk type is corrupted", async () => {
|
|
67
|
+
const corrupted = Buffer.from(
|
|
68
|
+
"89504e470d0a1a0a0000000d58485252000000010000000108" + "0".repeat(64),
|
|
69
|
+
"hex",
|
|
70
|
+
);
|
|
71
|
+
const filePath = await withTempFile(corrupted);
|
|
72
|
+
expect(await getImageDimensions(filePath)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("parses a GIF87a signature", async () => {
|
|
76
|
+
const gif87 = Buffer.concat([
|
|
77
|
+
Buffer.from("GIF87a", "latin1"),
|
|
78
|
+
Buffer.from([0x40, 0x00, 0x40, 0x00]), // 64x64 little-endian
|
|
79
|
+
Buffer.alloc(8),
|
|
80
|
+
]);
|
|
81
|
+
const filePath = await withTempFile(gif87);
|
|
82
|
+
expect(await getImageDimensions(filePath)).toEqual({ width: 64, height: 64 });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns null for a truncated GIF", async () => {
|
|
86
|
+
const filePath = await withTempFile(Buffer.from("GIF89a123", "latin1"));
|
|
87
|
+
expect(await getImageDimensions(filePath)).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns null for a JPEG with a malformed marker byte", async () => {
|
|
91
|
+
const garbage = Buffer.from("ffd8f000ffff00", "hex");
|
|
92
|
+
const filePath = await withTempFile(garbage);
|
|
93
|
+
expect(await getImageDimensions(filePath)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns null for a JPEG truncated inside its frame header", async () => {
|
|
97
|
+
const truncated = Buffer.from("ffd8ffc0000808003200", "hex");
|
|
98
|
+
const filePath = await withTempFile(truncated);
|
|
99
|
+
expect(await getImageDimensions(filePath)).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns null for a JPEG reaching start-of-scan without a frame header", async () => {
|
|
103
|
+
const noSof = Buffer.from("ffd8ffda00100000", "hex");
|
|
104
|
+
const filePath = await withTempFile(noSof);
|
|
105
|
+
expect(await getImageDimensions(filePath)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
function PNG_SIGNATURE_ONLY(): Buffer {
|
|
110
|
+
return Buffer.from("89504e470d0a1a0a", "hex");
|
|
111
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
jest.unmock("express-mysql-session");
|
|
2
|
+
jest.mock("../src/utils/dbManager");
|
|
3
|
+
jest.mock("mysql2/promise");
|
|
4
|
+
|
|
5
|
+
import buildIndexApp from "../src/index";
|
|
6
|
+
|
|
7
|
+
describe("MySQL session store (real express-mysql-session)", () => {
|
|
8
|
+
it("builds the app without throwing", () => {
|
|
9
|
+
expect(() =>
|
|
10
|
+
buildIndexApp({
|
|
11
|
+
name: "Test",
|
|
12
|
+
sessionSecret: "secret",
|
|
13
|
+
auth: { username: "admin", password: "admin-pass" },
|
|
14
|
+
mysql: { host: "localhost", user: "root", password: "", database: "db" },
|
|
15
|
+
views: [],
|
|
16
|
+
} as any),
|
|
17
|
+
).not.toThrow();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import * as bcrypt from "bcryptjs";
|
|
2
|
+
import * as request from "supertest";
|
|
3
|
+
|
|
4
|
+
import dbManagerMock from "../src/utils/dbManager";
|
|
5
|
+
jest.mock("../src/utils/dbManager");
|
|
6
|
+
jest.mock("express-mysql-session");
|
|
7
|
+
jest.mock("connect-pg-simple");
|
|
8
|
+
jest.mock("pg");
|
|
9
|
+
jest.mock("mysql2/promise");
|
|
10
|
+
jest.mock("argon2");
|
|
11
|
+
|
|
12
|
+
import * as express from "express";
|
|
13
|
+
import buildIndexApp from "../src/index";
|
|
14
|
+
import { fruitSchema } from "./helpers/fixtures";
|
|
15
|
+
|
|
16
|
+
const dbManager = dbManagerMock as any;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Builds the index app, then wires the mocked dbManager instance created by
|
|
20
|
+
* it. The instance only exists after the app is constructed, so the app is
|
|
21
|
+
* always built first.
|
|
22
|
+
*/
|
|
23
|
+
function buildAppWithDb(
|
|
24
|
+
config: any,
|
|
25
|
+
queryImpl: (sql: string, params?: any[]) => Promise<any>,
|
|
26
|
+
) {
|
|
27
|
+
const app = buildIndexApp(config);
|
|
28
|
+
const instances = dbManager.mock.instances;
|
|
29
|
+
const instance = instances[instances.length - 1];
|
|
30
|
+
instance.dbType = "MYSQL";
|
|
31
|
+
instance.init = jest.fn();
|
|
32
|
+
instance.query = jest.fn(queryImpl);
|
|
33
|
+
return { app, instance };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Standard query impl: answers information_schema probes from schemaRows. */
|
|
37
|
+
function withSchema(schemaRows: any[] = [], otherRows: any = []) {
|
|
38
|
+
return async (sql: string) => {
|
|
39
|
+
if (sql.includes("information_schema")) {
|
|
40
|
+
return { err: null, rows: schemaRows };
|
|
41
|
+
}
|
|
42
|
+
return { err: null, rows: otherRows };
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function usersRows(rows: any[]) {
|
|
47
|
+
return async (sql: string) => {
|
|
48
|
+
if (sql.includes("information_schema")) {
|
|
49
|
+
return { err: null, rows: fruitSchema() };
|
|
50
|
+
}
|
|
51
|
+
return { err: null, rows };
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mysqlConfig(overrides: any = {}) {
|
|
56
|
+
return {
|
|
57
|
+
name: "Test",
|
|
58
|
+
sessionSecret: "secret",
|
|
59
|
+
auth: { username: "admin", password: "admin-pass" },
|
|
60
|
+
mysql: { host: "localhost", user: "root", password: "", database: "db" },
|
|
61
|
+
views: [
|
|
62
|
+
{
|
|
63
|
+
type: "table",
|
|
64
|
+
label: "Fruits",
|
|
65
|
+
tableName: "fruits",
|
|
66
|
+
columns: [{ label: "Nom", name: "fruitName" }],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rolesConfig(rows: any[], roleFilters?: string) {
|
|
74
|
+
return mysqlConfig({
|
|
75
|
+
authRoles: [
|
|
76
|
+
{
|
|
77
|
+
role: "editor",
|
|
78
|
+
table: "users",
|
|
79
|
+
usernameField: "username",
|
|
80
|
+
passwordField: "passwordHash",
|
|
81
|
+
idField: "id",
|
|
82
|
+
...(roleFilters ? { filters: roleFilters } : {}),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
__usersRows: rows,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("index middleware (src/index.ts)", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
dbManager.mockClear();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("POST /api/login", () => {
|
|
95
|
+
it("logs the root user in from config.auth", async () => {
|
|
96
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema());
|
|
97
|
+
const res = await request(app)
|
|
98
|
+
.post("/api/login")
|
|
99
|
+
.send({ username: "admin", password: "admin-pass" })
|
|
100
|
+
.expect(200);
|
|
101
|
+
expect(res.body).toEqual({ success: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("rejects wrong root credentials", async () => {
|
|
105
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema());
|
|
106
|
+
const res = await request(app)
|
|
107
|
+
.post("/api/login")
|
|
108
|
+
.send({ username: "admin", password: "wrong" })
|
|
109
|
+
.expect(200);
|
|
110
|
+
expect(res.body).toEqual({ success: false });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("authenticates authRoles users against bcrypt hashes", async () => {
|
|
114
|
+
const hash = bcrypt.hashSync("secret", 4);
|
|
115
|
+
const { app } = buildAppWithDb(
|
|
116
|
+
rolesConfig([{ id: 3, username: "bob", passwordHash: hash }]),
|
|
117
|
+
usersRows([{ id: 3, username: "bob", passwordHash: hash }]),
|
|
118
|
+
);
|
|
119
|
+
const agent = request.agent(app);
|
|
120
|
+
const res = await agent
|
|
121
|
+
.post("/api/login")
|
|
122
|
+
.send({ username: "bob", password: "secret" })
|
|
123
|
+
.expect(200);
|
|
124
|
+
expect(res.body).toEqual({ success: true });
|
|
125
|
+
|
|
126
|
+
// the session grants access to the api
|
|
127
|
+
const auth = await agent.get("/api/isAuthenticated").expect(200);
|
|
128
|
+
expect(auth.body).toEqual({ isAuthenticated: true });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("matches plain-text stored passwords (legacy behavior)", async () => {
|
|
132
|
+
const { app } = buildAppWithDb(
|
|
133
|
+
rolesConfig([{ id: 4, username: "carol", passwordHash: "plain-secret" }]),
|
|
134
|
+
usersRows([{ id: 4, username: "carol", passwordHash: "plain-secret" }]),
|
|
135
|
+
);
|
|
136
|
+
await request(app)
|
|
137
|
+
.post("/api/login")
|
|
138
|
+
.send({ username: "carol", password: "plain-secret" })
|
|
139
|
+
.expect(200)
|
|
140
|
+
.expect({ success: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("verifies argon2 hashes when useArgon2 is set", async () => {
|
|
144
|
+
const argon = jest.requireMock("argon2");
|
|
145
|
+
const { app } = buildAppWithDb(
|
|
146
|
+
mysqlConfig({
|
|
147
|
+
authRoles: [
|
|
148
|
+
{
|
|
149
|
+
role: "editor",
|
|
150
|
+
table: "users",
|
|
151
|
+
usernameField: "username",
|
|
152
|
+
passwordField: "passwordHash",
|
|
153
|
+
idField: "id",
|
|
154
|
+
useArgon2: true,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
}),
|
|
158
|
+
usersRows([{ id: 5, username: "dave", passwordHash: "$argon2id$hash" }]),
|
|
159
|
+
);
|
|
160
|
+
await request(app)
|
|
161
|
+
.post("/api/login")
|
|
162
|
+
.send({ username: "dave", password: "argon-secret" })
|
|
163
|
+
.expect(200)
|
|
164
|
+
.expect({ success: true });
|
|
165
|
+
expect(argon.verify).toHaveBeenCalledWith("$argon2id$hash", "argon-secret");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("returns success:false for unknown authRoles users", async () => {
|
|
169
|
+
const { app } = buildAppWithDb(rolesConfig([]), usersRows([]));
|
|
170
|
+
await request(app)
|
|
171
|
+
.post("/api/login")
|
|
172
|
+
.send({ username: "nobody", password: "x" })
|
|
173
|
+
.expect(200)
|
|
174
|
+
.expect({ success: false });
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("auth middleware", () => {
|
|
179
|
+
it("blocks unauthenticated api access with 401", async () => {
|
|
180
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
181
|
+
await request(app).post("/api/records").send({}).expect(401);
|
|
182
|
+
await request(app).get("/api/record").query({ viewIndex: 0 }).expect(401);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("invalidates the session when the user record disappears", async () => {
|
|
186
|
+
const hash = bcrypt.hashSync("secret", 4);
|
|
187
|
+
let userExists = true;
|
|
188
|
+
const { app } = buildAppWithDb(
|
|
189
|
+
rolesConfig([]),
|
|
190
|
+
async (sql: string) => {
|
|
191
|
+
if (sql.includes("information_schema")) {
|
|
192
|
+
return { err: null, rows: fruitSchema() };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
err: null,
|
|
196
|
+
rows: userExists
|
|
197
|
+
? [{ id: 3, username: "bob", passwordHash: hash }]
|
|
198
|
+
: [],
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
const agent = request.agent(app);
|
|
203
|
+
await agent
|
|
204
|
+
.post("/api/login")
|
|
205
|
+
.send({ username: "bob", password: "secret" })
|
|
206
|
+
.expect(200);
|
|
207
|
+
|
|
208
|
+
userExists = false; // the account is deactivated between requests
|
|
209
|
+
const res = await agent
|
|
210
|
+
.post("/api/records")
|
|
211
|
+
.send({ viewIndex: 0, page: 1, filters: [] })
|
|
212
|
+
.expect(401);
|
|
213
|
+
expect(res.body).toEqual({});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("applies the role filters when re-checking the user", async () => {
|
|
217
|
+
const hash = bcrypt.hashSync("secret", 4);
|
|
218
|
+
let filterMatches = true;
|
|
219
|
+
const { app } = buildAppWithDb(
|
|
220
|
+
rolesConfig([], "active = 1"),
|
|
221
|
+
async (sql: string, params: any[] = []) => {
|
|
222
|
+
if (sql.includes("information_schema")) {
|
|
223
|
+
return { err: null, rows: fruitSchema() };
|
|
224
|
+
}
|
|
225
|
+
if (filterMatches) {
|
|
226
|
+
return {
|
|
227
|
+
err: null,
|
|
228
|
+
rows: [
|
|
229
|
+
{
|
|
230
|
+
id: 3,
|
|
231
|
+
username: "bob",
|
|
232
|
+
passwordHash: hash,
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { err: null, rows: [] };
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
const agent = request.agent(app);
|
|
241
|
+
await agent
|
|
242
|
+
.post("/api/login")
|
|
243
|
+
.send({ username: "bob", password: "secret" })
|
|
244
|
+
.expect(200);
|
|
245
|
+
|
|
246
|
+
// while the filter matches, the api stays reachable
|
|
247
|
+
await agent
|
|
248
|
+
.post("/api/records")
|
|
249
|
+
.send({ viewIndex: 0, page: 1, filters: [] })
|
|
250
|
+
.expect(200);
|
|
251
|
+
|
|
252
|
+
// simulate the filter no longer matching
|
|
253
|
+
filterMatches = false;
|
|
254
|
+
await agent
|
|
255
|
+
.post("/api/records")
|
|
256
|
+
.send({ viewIndex: 0, page: 1, filters: [] })
|
|
257
|
+
.expect(401);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("GET /api/isAuthenticated and /api/logout", () => {
|
|
262
|
+
it("reports unauthenticated state before login", async () => {
|
|
263
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
264
|
+
const res = await request(app).get("/api/isAuthenticated").expect(200);
|
|
265
|
+
expect(res.body).toEqual({ isAuthenticated: false });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("destroys the session on logout", async () => {
|
|
269
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
270
|
+
const agent = request.agent(app);
|
|
271
|
+
await agent
|
|
272
|
+
.post("/api/login")
|
|
273
|
+
.send({ username: "admin", password: "admin-pass" })
|
|
274
|
+
.expect(200);
|
|
275
|
+
await agent.get("/api/isAuthenticated").expect(200);
|
|
276
|
+
await agent.get("/api/logout").expect(200);
|
|
277
|
+
const res = await agent.get("/api/isAuthenticated").expect(200);
|
|
278
|
+
expect(res.body).toEqual({ isAuthenticated: false });
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("session store selection", () => {
|
|
283
|
+
it("uses the postgres store when config.postgresql is set", () => {
|
|
284
|
+
buildAppWithDb(
|
|
285
|
+
mysqlConfig({
|
|
286
|
+
mysql: undefined,
|
|
287
|
+
postgresql: { host: "h", user: "u", password: "p", database: "d" },
|
|
288
|
+
}),
|
|
289
|
+
withSchema(),
|
|
290
|
+
);
|
|
291
|
+
const pgSession = jest.requireMock("connect-pg-simple");
|
|
292
|
+
const pg = jest.requireMock("pg");
|
|
293
|
+
expect(pgSession.__PgSessionStore).toHaveBeenCalled();
|
|
294
|
+
expect(pg.Pool).toHaveBeenCalled();
|
|
295
|
+
expect(pgSession.__storeConstructor).toHaveBeenCalledWith(
|
|
296
|
+
expect.objectContaining({ tableName: "galadmin_sessions" }),
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("uses the mysql store otherwise", () => {
|
|
301
|
+
buildAppWithDb(mysqlConfig(), withSchema());
|
|
302
|
+
const sessionStoreMock = jest.requireMock("express-mysql-session");
|
|
303
|
+
const mysql2Promise = jest.requireMock("mysql2/promise");
|
|
304
|
+
expect(sessionStoreMock.__MySQLSessionStore).toHaveBeenCalledWith(
|
|
305
|
+
require("express-session"),
|
|
306
|
+
);
|
|
307
|
+
expect(sessionStoreMock.__storeConstructor).toHaveBeenCalledWith(
|
|
308
|
+
expect.objectContaining({
|
|
309
|
+
schema: { tableName: "galadmin_sessions" },
|
|
310
|
+
}),
|
|
311
|
+
expect.anything(),
|
|
312
|
+
);
|
|
313
|
+
expect(mysql2Promise.createPool).toHaveBeenCalledWith(
|
|
314
|
+
expect.objectContaining({ database: "db", port: 3306 }),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("static file serving", () => {
|
|
320
|
+
it("serves index.html with the base url substituted for /_HOMEPAGE_", async () => {
|
|
321
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
322
|
+
const fsModule = require("fs");
|
|
323
|
+
const existsSpy = jest.spyOn(fsModule, "existsSync").mockReturnValue(true);
|
|
324
|
+
const readSpy = jest
|
|
325
|
+
.spyOn(fsModule, "readFileSync")
|
|
326
|
+
.mockReturnValue('<html href="/_HOMEPAGE_/x"></html>' as any);
|
|
327
|
+
// mount like a real integration: app.use("/admin", galadmin(config))
|
|
328
|
+
const outer = express();
|
|
329
|
+
outer.use("/admin", app);
|
|
330
|
+
try {
|
|
331
|
+
// "/" maps directly to index.html (has an extension, so mime resolves)
|
|
332
|
+
const res = await request(outer).get("/admin/").expect(200);
|
|
333
|
+
expect(res.text).toBe('<html href="/admin/x"></html>');
|
|
334
|
+
expect(res.headers["content-type"]).toContain("text/html");
|
|
335
|
+
} finally {
|
|
336
|
+
existsSpy.mockRestore();
|
|
337
|
+
readSpy.mockRestore();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("falls back to index.html for unknown paths (SPA routing)", async () => {
|
|
342
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
343
|
+
const fsModule = require("fs");
|
|
344
|
+
// only the built index.html "exists" — any other path falls back to it
|
|
345
|
+
const existsSpy = jest
|
|
346
|
+
.spyOn(fsModule, "existsSync")
|
|
347
|
+
.mockImplementation((p: any) => String(p).endsWith("index.html"));
|
|
348
|
+
const readSpy = jest
|
|
349
|
+
.spyOn(fsModule, "readFileSync")
|
|
350
|
+
.mockReturnValue('<html href="/_HOMEPAGE_/x"></html>' as any);
|
|
351
|
+
const outer = express();
|
|
352
|
+
outer.use("/admin", app);
|
|
353
|
+
try {
|
|
354
|
+
const res = await request(outer).get("/admin/records/0").expect(200);
|
|
355
|
+
expect(res.text).toBe('<html href="/admin/x"></html>');
|
|
356
|
+
expect(existsSpy).toHaveBeenCalled();
|
|
357
|
+
} finally {
|
|
358
|
+
existsSpy.mockRestore();
|
|
359
|
+
readSpy.mockRestore();
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
});
|