@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.
Files changed (66) hide show
  1. package/.github/workflows/main.yml +34 -3
  2. package/README.md +65 -0
  3. package/dist/api.d.ts +16 -0
  4. package/dist/api.js +240 -189
  5. package/dist/api.js.map +1 -1
  6. package/dist/index.js +25 -3
  7. package/dist/index.js.map +1 -1
  8. package/dist/tsconfig.tsbuildinfo +1 -1
  9. package/dist/types/config.d.ts +6 -1
  10. package/dist/utils/dbManager.d.ts +5 -0
  11. package/dist/utils/dbManager.js +146 -11
  12. package/dist/utils/dbManager.js.map +1 -1
  13. package/dist/utils/imageDimensions.d.ts +5 -0
  14. package/dist/utils/imageDimensions.js +98 -0
  15. package/dist/utils/imageDimensions.js.map +1 -0
  16. package/jest.config.js +43 -0
  17. package/jest.integration.config.js +16 -0
  18. package/package.json +30 -10
  19. package/src/api.ts +284 -217
  20. package/src/front/src/App.test.js +28 -0
  21. package/src/front/src/components/OutsideClickHandler.test.js +53 -0
  22. package/src/front/src/components/RedirectToFirstView.test.js +38 -0
  23. package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
  24. package/src/front/src/utils/evalExpression.test.js +29 -0
  25. package/src/index.ts +26 -3
  26. package/src/types/config.ts +13 -1
  27. package/src/utils/dbManager.ts +243 -102
  28. package/src/utils/imageDimensions.ts +112 -0
  29. package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
  30. package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
  31. package/tests/__mocks__/@google-cloud/storage.js +7 -0
  32. package/tests/__mocks__/argon2.js +3 -0
  33. package/tests/__mocks__/axios.js +7 -0
  34. package/tests/__mocks__/connect-pg-simple.js +24 -0
  35. package/tests/__mocks__/express-mysql-session.js +24 -0
  36. package/tests/__mocks__/mysql2/promise.js +3 -0
  37. package/tests/__mocks__/pg.js +3 -0
  38. package/tests/__mocks__/rollup.js +5 -0
  39. package/tests/__mocks__/ssh2-sftp-client.js +10 -0
  40. package/tests/api-helpers.test.ts +376 -0
  41. package/tests/dbManager.init.test.ts +129 -0
  42. package/tests/dbManager.test.ts +265 -0
  43. package/tests/edge-cases.test.ts +167 -0
  44. package/tests/helpers/app.ts +69 -0
  45. package/tests/helpers/fakeDb.ts +66 -0
  46. package/tests/helpers/fakePool.ts +76 -0
  47. package/tests/helpers/fixtures.ts +68 -0
  48. package/tests/imageDimensions.test.ts +111 -0
  49. package/tests/index.sessionStore.test.ts +19 -0
  50. package/tests/index.test.ts +412 -0
  51. package/tests/integration/__mocks__/axios.js +3 -0
  52. package/tests/integration/dbHarness.ts +381 -0
  53. package/tests/integration/dbManager.integration.test.ts +69 -0
  54. package/tests/integration/globalSetup.ts +43 -0
  55. package/tests/integration/globalTeardown.ts +42 -0
  56. package/tests/integration/routes.authorization.integration.test.ts +319 -0
  57. package/tests/integration/routes.config.integration.test.ts +189 -0
  58. package/tests/integration/routes.crud.integration.test.ts +805 -0
  59. package/tests/integration/routes.custom.integration.test.ts +376 -0
  60. package/tests/integration/routes.foreign.integration.test.ts +306 -0
  61. package/tests/integration/routes.importExport.integration.test.ts +404 -0
  62. package/tests/integration/routes.integration.test.ts +366 -0
  63. package/tests/integration/routes.postgresql.integration.test.ts +88 -0
  64. package/tests/integration/routes.records.integration.test.ts +634 -0
  65. package/tests/integration/routes.upload.integration.test.ts +425 -0
  66. 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
+ });
@@ -0,0 +1,3 @@
1
+ // Scoped copy: this project's roots don't include tests/__mocks__, so without
2
+ // this file jest.mock("axios") would silently fall back to automock.
3
+ module.exports = require("../../__mocks__/axios.js");