@galadrim/galadmin 0.1.236 → 0.2.7
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 +34 -3
- 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 +25 -3
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/config.d.ts +6 -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 +26 -3
- package/src/types/config.ts +13 -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 +412 -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,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scripted pool injected into a REAL dbManager instance.
|
|
3
|
+
*
|
|
4
|
+
* While FakeDb stands in for the whole dbManager (so tests assert the raw,
|
|
5
|
+
* MySQL-flavoured SQL emitted by the routes), FakePool replaces only the
|
|
6
|
+
* underlying driver pool. The real dbManager.query() still runs on top of it,
|
|
7
|
+
* which means the full POSTGRESQL conversion pipeline
|
|
8
|
+
* (convertQuery -> quotePostgresQuery) is exercised and the recorded SQL is
|
|
9
|
+
* what would actually reach the pg driver.
|
|
10
|
+
*
|
|
11
|
+
* Contract mirrors FakeDb:
|
|
12
|
+
* - every executed query is recorded in `queries`
|
|
13
|
+
* - `information_schema` probes are answered from `schemaRows`
|
|
14
|
+
* - everything else consumes the FIFO `queue`; an empty queue yields
|
|
15
|
+
* `{ err: null, rows: [] }`
|
|
16
|
+
* - results are shaped for the driver the dbManager wraps: MySQL receives
|
|
17
|
+
* (err, rows, fields), pg receives (null, { rows, fields }).
|
|
18
|
+
*/
|
|
19
|
+
export type FakeQueryResult = { err?: any; rows?: any; fields?: any };
|
|
20
|
+
export type RecordedQuery = { sql: string; params: any[] };
|
|
21
|
+
|
|
22
|
+
export class FakePool {
|
|
23
|
+
dbType: "MYSQL" | "POSTGRESQL" = "MYSQL";
|
|
24
|
+
schemaRows: any[] = [];
|
|
25
|
+
queries: RecordedQuery[] = [];
|
|
26
|
+
private queue: FakeQueryResult[] = [];
|
|
27
|
+
defaultResult: FakeQueryResult = { err: null, rows: [] };
|
|
28
|
+
|
|
29
|
+
queueResult(result: FakeQueryResult) {
|
|
30
|
+
this.queue.push({ err: null, rows: [], ...result });
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
queueRows(rows: any) {
|
|
35
|
+
return this.queueResult({ err: null, rows });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
queueErr(err: any) {
|
|
39
|
+
return this.queueResult({ err, rows: null });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
query(sql: string, params: any[] = [], callback: any) {
|
|
43
|
+
this.queries.push({ sql, params });
|
|
44
|
+
let result: FakeQueryResult;
|
|
45
|
+
if (typeof sql === "string" && sql.includes("information_schema")) {
|
|
46
|
+
result = { err: null, rows: this.schemaRows };
|
|
47
|
+
} else if (this.queue.length > 0) {
|
|
48
|
+
result = this.queue.shift()!;
|
|
49
|
+
} else {
|
|
50
|
+
result = this.defaultResult;
|
|
51
|
+
}
|
|
52
|
+
if (this.dbType === "POSTGRESQL") {
|
|
53
|
+
callback(null, { rows: result.rows, fields: result.fields });
|
|
54
|
+
} else {
|
|
55
|
+
callback(result.err, result.rows, result.fields);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
reset() {
|
|
60
|
+
this.queue = [];
|
|
61
|
+
this.queries = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lastQuery(): RecordedQuery {
|
|
65
|
+
return this.queries[this.queries.length - 1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Queries excluding the information_schema enrichment probe. */
|
|
69
|
+
dataQueries(): RecordedQuery[] {
|
|
70
|
+
return this.queries.filter((q) => !q.sql.includes("information_schema"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
queriesMatching(substring: string): RecordedQuery[] {
|
|
74
|
+
return this.dataQueries().filter((q) => q.sql.includes(substring));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -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,412 @@
|
|
|
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("trustedAuth", () => {
|
|
262
|
+
const trusted = (user: any) =>
|
|
263
|
+
mysqlConfig({ trustedAuth: jest.fn(async () => user) });
|
|
264
|
+
|
|
265
|
+
it("authenticates from the host app, without a galadmin login", async () => {
|
|
266
|
+
const { app } = buildAppWithDb(trusted({ role: "root", userId: 7 }), withSchema());
|
|
267
|
+
const auth = await request(app).get("/api/isAuthenticated").expect(200);
|
|
268
|
+
expect(auth.body).toEqual({ isAuthenticated: true });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("refuses the api when the host app returns no user", async () => {
|
|
272
|
+
const { app } = buildAppWithDb(trusted(null), withSchema());
|
|
273
|
+
await request(app).get("/api/config").expect(401);
|
|
274
|
+
const auth = await request(app).get("/api/isAuthenticated").expect(200);
|
|
275
|
+
expect(auth.body).toEqual({ isAuthenticated: false });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("refuses the password login, shared account included", async () => {
|
|
279
|
+
const { app } = buildAppWithDb(trusted(null), withSchema());
|
|
280
|
+
const res = await request(app)
|
|
281
|
+
.post("/api/login")
|
|
282
|
+
.send({ username: "admin", password: "admin-pass" })
|
|
283
|
+
.expect(200);
|
|
284
|
+
expect(res.body).toEqual({ success: false });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("drops the access as soon as the host app stops vouching for the user", async () => {
|
|
288
|
+
let user: any = { role: "user", userId: 3 };
|
|
289
|
+
const config = mysqlConfig({ trustedAuth: jest.fn(async () => user) });
|
|
290
|
+
const { app } = buildAppWithDb(config, withSchema());
|
|
291
|
+
const agent = request.agent(app);
|
|
292
|
+
expect((await agent.get("/api/isAuthenticated")).body).toEqual({ isAuthenticated: true });
|
|
293
|
+
user = null;
|
|
294
|
+
expect((await agent.get("/api/isAuthenticated")).body).toEqual({ isAuthenticated: false });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("never runs the authRoles re-check against the database", async () => {
|
|
298
|
+
const config = mysqlConfig({
|
|
299
|
+
trustedAuth: jest.fn(async () => ({ role: "editor", userId: 3 })),
|
|
300
|
+
authRoles: [
|
|
301
|
+
{ role: "editor", table: "users", usernameField: "u", passwordField: "p", idField: "id" },
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
const { app, instance } = buildAppWithDb(config, withSchema());
|
|
305
|
+
await request(app).get("/api/isAuthenticated").expect(200);
|
|
306
|
+
expect(instance.query.mock.calls.some(([sql]: any[]) => /FROM \?\? WHERE \?\? = \?/.test(sql))).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("GET /api/isAuthenticated and /api/logout", () => {
|
|
311
|
+
it("reports unauthenticated state before login", async () => {
|
|
312
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
313
|
+
const res = await request(app).get("/api/isAuthenticated").expect(200);
|
|
314
|
+
expect(res.body).toEqual({ isAuthenticated: false });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("destroys the session on logout", async () => {
|
|
318
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
319
|
+
const agent = request.agent(app);
|
|
320
|
+
await agent
|
|
321
|
+
.post("/api/login")
|
|
322
|
+
.send({ username: "admin", password: "admin-pass" })
|
|
323
|
+
.expect(200);
|
|
324
|
+
await agent.get("/api/isAuthenticated").expect(200);
|
|
325
|
+
await agent.get("/api/logout").expect(200);
|
|
326
|
+
const res = await agent.get("/api/isAuthenticated").expect(200);
|
|
327
|
+
expect(res.body).toEqual({ isAuthenticated: false });
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("session store selection", () => {
|
|
332
|
+
it("uses the postgres store when config.postgresql is set", () => {
|
|
333
|
+
buildAppWithDb(
|
|
334
|
+
mysqlConfig({
|
|
335
|
+
mysql: undefined,
|
|
336
|
+
postgresql: { host: "h", user: "u", password: "p", database: "d" },
|
|
337
|
+
}),
|
|
338
|
+
withSchema(),
|
|
339
|
+
);
|
|
340
|
+
const pgSession = jest.requireMock("connect-pg-simple");
|
|
341
|
+
const pg = jest.requireMock("pg");
|
|
342
|
+
expect(pgSession.__PgSessionStore).toHaveBeenCalled();
|
|
343
|
+
expect(pg.Pool).toHaveBeenCalled();
|
|
344
|
+
expect(pgSession.__storeConstructor).toHaveBeenCalledWith(
|
|
345
|
+
expect.objectContaining({ tableName: "galadmin_sessions" }),
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("uses the mysql store otherwise", () => {
|
|
350
|
+
buildAppWithDb(mysqlConfig(), withSchema());
|
|
351
|
+
const sessionStoreMock = jest.requireMock("express-mysql-session");
|
|
352
|
+
const mysql2Promise = jest.requireMock("mysql2/promise");
|
|
353
|
+
expect(sessionStoreMock.__MySQLSessionStore).toHaveBeenCalledWith(
|
|
354
|
+
require("express-session"),
|
|
355
|
+
);
|
|
356
|
+
expect(sessionStoreMock.__storeConstructor).toHaveBeenCalledWith(
|
|
357
|
+
expect.objectContaining({
|
|
358
|
+
schema: { tableName: "galadmin_sessions" },
|
|
359
|
+
}),
|
|
360
|
+
expect.anything(),
|
|
361
|
+
);
|
|
362
|
+
expect(mysql2Promise.createPool).toHaveBeenCalledWith(
|
|
363
|
+
expect.objectContaining({ database: "db", port: 3306 }),
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("static file serving", () => {
|
|
369
|
+
it("serves index.html with the base url substituted for /_HOMEPAGE_", async () => {
|
|
370
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
371
|
+
const fsModule = require("fs");
|
|
372
|
+
const existsSpy = jest.spyOn(fsModule, "existsSync").mockReturnValue(true);
|
|
373
|
+
const readSpy = jest
|
|
374
|
+
.spyOn(fsModule, "readFileSync")
|
|
375
|
+
.mockReturnValue('<html href="/_HOMEPAGE_/x"></html>' as any);
|
|
376
|
+
// mount like a real integration: app.use("/admin", galadmin(config))
|
|
377
|
+
const outer = express();
|
|
378
|
+
outer.use("/admin", app);
|
|
379
|
+
try {
|
|
380
|
+
// "/" maps directly to index.html (has an extension, so mime resolves)
|
|
381
|
+
const res = await request(outer).get("/admin/").expect(200);
|
|
382
|
+
expect(res.text).toBe('<html href="/admin/x"></html>');
|
|
383
|
+
expect(res.headers["content-type"]).toContain("text/html");
|
|
384
|
+
} finally {
|
|
385
|
+
existsSpy.mockRestore();
|
|
386
|
+
readSpy.mockRestore();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("falls back to index.html for unknown paths (SPA routing)", async () => {
|
|
391
|
+
const { app } = buildAppWithDb(mysqlConfig(), withSchema(fruitSchema()));
|
|
392
|
+
const fsModule = require("fs");
|
|
393
|
+
// only the built index.html "exists" — any other path falls back to it
|
|
394
|
+
const existsSpy = jest
|
|
395
|
+
.spyOn(fsModule, "existsSync")
|
|
396
|
+
.mockImplementation((p: any) => String(p).endsWith("index.html"));
|
|
397
|
+
const readSpy = jest
|
|
398
|
+
.spyOn(fsModule, "readFileSync")
|
|
399
|
+
.mockReturnValue('<html href="/_HOMEPAGE_/x"></html>' as any);
|
|
400
|
+
const outer = express();
|
|
401
|
+
outer.use("/admin", app);
|
|
402
|
+
try {
|
|
403
|
+
const res = await request(outer).get("/admin/records/0").expect(200);
|
|
404
|
+
expect(res.text).toBe('<html href="/admin/x"></html>');
|
|
405
|
+
expect(existsSpy).toHaveBeenCalled();
|
|
406
|
+
} finally {
|
|
407
|
+
existsSpy.mockRestore();
|
|
408
|
+
readSpy.mockRestore();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|