@terreno/api 0.0.4 → 0.0.11-beta.1
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/CLAUDE.md +107 -0
- package/bunfig.toml +3 -2
- package/dist/api.d.ts +6 -17
- package/dist/api.js +44 -68
- package/dist/api.test.js +2051 -166
- package/dist/expressServer.d.ts +2 -2
- package/dist/openApi.d.ts +7 -7
- package/dist/openApiBuilder.d.ts +3 -3
- package/dist/permissions.d.ts +2 -2
- package/dist/permissions.js +17 -25
- package/dist/transformers.d.ts +4 -4
- package/dist/utils.test.js +169 -7
- package/package.json +3 -2
- package/src/api.test.ts +1736 -142
- package/src/api.ts +22 -64
- package/src/example.ts +2 -2
- package/src/expressServer.ts +2 -2
- package/src/openApi.test.ts +4 -4
- package/src/openApi.ts +7 -7
- package/src/openApiBuilder.test.ts +2 -2
- package/src/openApiBuilder.ts +4 -4
- package/src/permissions.ts +4 -14
- package/src/transformers.ts +4 -4
- package/src/utils.test.ts +189 -9
package/src/api.test.ts
CHANGED
|
@@ -1,29 +1,17 @@
|
|
|
1
1
|
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
2
|
import * as Sentry from "@sentry/node";
|
|
3
3
|
import type express from "express";
|
|
4
|
-
import sortBy from "lodash/sortBy";
|
|
5
|
-
import type mongoose from "mongoose";
|
|
6
4
|
import qs from "qs";
|
|
7
5
|
import supertest from "supertest";
|
|
8
6
|
import type TestAgent from "supertest/lib/agent";
|
|
9
7
|
|
|
10
|
-
import {modelRouter} from "./api";
|
|
8
|
+
import {addPopulateToQuery, modelRouter} from "./api";
|
|
11
9
|
import {addAuthRoutes, setupAuth} from "./auth";
|
|
12
10
|
import {APIError} from "./errors";
|
|
13
11
|
import {logRequests} from "./expressServer";
|
|
14
12
|
import {Permissions} from "./permissions";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
type Food,
|
|
18
|
-
FoodModel,
|
|
19
|
-
getBaseServer,
|
|
20
|
-
type StaffUser,
|
|
21
|
-
StaffUserModel,
|
|
22
|
-
type SuperUser,
|
|
23
|
-
SuperUserModel,
|
|
24
|
-
setupDb,
|
|
25
|
-
UserModel,
|
|
26
|
-
} from "./tests";
|
|
13
|
+
import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
|
|
14
|
+
import {AdminOwnerTransformer} from "./transformers";
|
|
27
15
|
|
|
28
16
|
describe("@terreno/api", () => {
|
|
29
17
|
let server: TestAgent;
|
|
@@ -1474,188 +1462,1794 @@ describe("@terreno/api", () => {
|
|
|
1474
1462
|
});
|
|
1475
1463
|
});
|
|
1476
1464
|
|
|
1477
|
-
describe("
|
|
1478
|
-
let
|
|
1479
|
-
let
|
|
1480
|
-
let
|
|
1465
|
+
describe("error handling", () => {
|
|
1466
|
+
let admin: any;
|
|
1467
|
+
let agent: TestAgent;
|
|
1468
|
+
let spinach: Food;
|
|
1469
|
+
|
|
1470
|
+
beforeEach(async () => {
|
|
1471
|
+
[admin] = await setupDb();
|
|
1472
|
+
|
|
1473
|
+
spinach = await FoodModel.create({
|
|
1474
|
+
calories: 1,
|
|
1475
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
1476
|
+
hidden: false,
|
|
1477
|
+
name: "Spinach",
|
|
1478
|
+
ownerId: admin._id,
|
|
1479
|
+
source: {
|
|
1480
|
+
name: "Brand",
|
|
1481
|
+
},
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
app = getBaseServer();
|
|
1485
|
+
setupAuth(app, UserModel as any);
|
|
1486
|
+
addAuthRoutes(app, UserModel as any);
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it("PUT returns 500 not supported", async () => {
|
|
1490
|
+
app.use(
|
|
1491
|
+
"/food",
|
|
1492
|
+
modelRouter(FoodModel, {
|
|
1493
|
+
allowAnonymous: true,
|
|
1494
|
+
permissions: {
|
|
1495
|
+
create: [Permissions.IsAny],
|
|
1496
|
+
delete: [Permissions.IsAny],
|
|
1497
|
+
list: [Permissions.IsAny],
|
|
1498
|
+
read: [Permissions.IsAny],
|
|
1499
|
+
update: [Permissions.IsAny],
|
|
1500
|
+
},
|
|
1501
|
+
})
|
|
1502
|
+
);
|
|
1503
|
+
server = supertest(app);
|
|
1504
|
+
|
|
1505
|
+
const res = await server.put(`/food/${spinach._id}`).send({name: "Kale"}).expect(500);
|
|
1506
|
+
expect(res.body.title).toBe("PUT is not supported.");
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it("preCreate returning undefined throws error", async () => {
|
|
1510
|
+
app.use(
|
|
1511
|
+
"/food",
|
|
1512
|
+
modelRouter(FoodModel, {
|
|
1513
|
+
allowAnonymous: true,
|
|
1514
|
+
permissions: {
|
|
1515
|
+
create: [Permissions.IsAny],
|
|
1516
|
+
delete: [Permissions.IsAny],
|
|
1517
|
+
list: [Permissions.IsAny],
|
|
1518
|
+
read: [Permissions.IsAny],
|
|
1519
|
+
update: [Permissions.IsAny],
|
|
1520
|
+
},
|
|
1521
|
+
preCreate: () => undefined as any,
|
|
1522
|
+
})
|
|
1523
|
+
);
|
|
1524
|
+
server = supertest(app);
|
|
1525
|
+
|
|
1526
|
+
const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(403);
|
|
1527
|
+
expect(res.body.title).toBe("Create not allowed");
|
|
1528
|
+
expect(res.body.detail).toBe("A body must be returned from preCreate");
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it("preUpdate returning undefined throws error", async () => {
|
|
1532
|
+
app.use(
|
|
1533
|
+
"/food",
|
|
1534
|
+
modelRouter(FoodModel, {
|
|
1535
|
+
allowAnonymous: true,
|
|
1536
|
+
permissions: {
|
|
1537
|
+
create: [Permissions.IsAny],
|
|
1538
|
+
delete: [Permissions.IsAny],
|
|
1539
|
+
list: [Permissions.IsAny],
|
|
1540
|
+
read: [Permissions.IsAny],
|
|
1541
|
+
update: [Permissions.IsAny],
|
|
1542
|
+
},
|
|
1543
|
+
preUpdate: () => undefined as any,
|
|
1544
|
+
})
|
|
1545
|
+
);
|
|
1546
|
+
server = supertest(app);
|
|
1547
|
+
|
|
1548
|
+
const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(403);
|
|
1549
|
+
expect(res.body.title).toBe("Update not allowed");
|
|
1550
|
+
expect(res.body.detail).toBe("A body must be returned from preUpdate");
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it("preDelete returning undefined throws error", async () => {
|
|
1554
|
+
app.use(
|
|
1555
|
+
"/food",
|
|
1556
|
+
modelRouter(FoodModel, {
|
|
1557
|
+
allowAnonymous: true,
|
|
1558
|
+
permissions: {
|
|
1559
|
+
create: [Permissions.IsAny],
|
|
1560
|
+
delete: [Permissions.IsAny],
|
|
1561
|
+
list: [Permissions.IsAny],
|
|
1562
|
+
read: [Permissions.IsAny],
|
|
1563
|
+
update: [Permissions.IsAny],
|
|
1564
|
+
},
|
|
1565
|
+
preDelete: () => undefined as any,
|
|
1566
|
+
})
|
|
1567
|
+
);
|
|
1568
|
+
server = supertest(app);
|
|
1569
|
+
agent = await authAsUser(app, "notAdmin");
|
|
1570
|
+
|
|
1571
|
+
const res = await agent.delete(`/food/${spinach._id}`).expect(403);
|
|
1572
|
+
expect(res.body.title).toBe("Delete not allowed");
|
|
1573
|
+
expect(res.body.detail).toBe("A body must be returned from preDelete");
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it("postCreate hook error is handled", async () => {
|
|
1577
|
+
app.use(
|
|
1578
|
+
"/food",
|
|
1579
|
+
modelRouter(FoodModel, {
|
|
1580
|
+
allowAnonymous: true,
|
|
1581
|
+
permissions: {
|
|
1582
|
+
create: [Permissions.IsAny],
|
|
1583
|
+
delete: [Permissions.IsAny],
|
|
1584
|
+
list: [Permissions.IsAny],
|
|
1585
|
+
read: [Permissions.IsAny],
|
|
1586
|
+
update: [Permissions.IsAny],
|
|
1587
|
+
},
|
|
1588
|
+
postCreate: () => {
|
|
1589
|
+
throw new Error("postCreate failed");
|
|
1590
|
+
},
|
|
1591
|
+
})
|
|
1592
|
+
);
|
|
1593
|
+
server = supertest(app);
|
|
1594
|
+
|
|
1595
|
+
const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
|
|
1596
|
+
expect(res.body.title).toContain("postCreate hook error");
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
it("postUpdate hook error is handled", async () => {
|
|
1600
|
+
app.use(
|
|
1601
|
+
"/food",
|
|
1602
|
+
modelRouter(FoodModel, {
|
|
1603
|
+
allowAnonymous: true,
|
|
1604
|
+
permissions: {
|
|
1605
|
+
create: [Permissions.IsAny],
|
|
1606
|
+
delete: [Permissions.IsAny],
|
|
1607
|
+
list: [Permissions.IsAny],
|
|
1608
|
+
read: [Permissions.IsAny],
|
|
1609
|
+
update: [Permissions.IsAny],
|
|
1610
|
+
},
|
|
1611
|
+
postUpdate: () => {
|
|
1612
|
+
throw new Error("postUpdate failed");
|
|
1613
|
+
},
|
|
1614
|
+
})
|
|
1615
|
+
);
|
|
1616
|
+
server = supertest(app);
|
|
1617
|
+
|
|
1618
|
+
const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(400);
|
|
1619
|
+
expect(res.body.title).toContain("postUpdate hook error");
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
it("postDelete hook error is handled", async () => {
|
|
1623
|
+
app.use(
|
|
1624
|
+
"/food",
|
|
1625
|
+
modelRouter(FoodModel, {
|
|
1626
|
+
allowAnonymous: true,
|
|
1627
|
+
permissions: {
|
|
1628
|
+
create: [Permissions.IsAny],
|
|
1629
|
+
delete: [Permissions.IsAny],
|
|
1630
|
+
list: [Permissions.IsAny],
|
|
1631
|
+
read: [Permissions.IsAny],
|
|
1632
|
+
update: [Permissions.IsAny],
|
|
1633
|
+
},
|
|
1634
|
+
postDelete: () => {
|
|
1635
|
+
throw new Error("postDelete failed");
|
|
1636
|
+
},
|
|
1637
|
+
})
|
|
1638
|
+
);
|
|
1639
|
+
server = supertest(app);
|
|
1640
|
+
agent = await authAsUser(app, "notAdmin");
|
|
1641
|
+
|
|
1642
|
+
const res = await agent.delete(`/food/${spinach._id}`).expect(400);
|
|
1643
|
+
expect(res.body.title).toContain("postDelete hook error");
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
it("responseHandler error in read is handled", async () => {
|
|
1647
|
+
app.use(
|
|
1648
|
+
"/food",
|
|
1649
|
+
modelRouter(FoodModel, {
|
|
1650
|
+
allowAnonymous: true,
|
|
1651
|
+
permissions: {
|
|
1652
|
+
create: [Permissions.IsAny],
|
|
1653
|
+
delete: [Permissions.IsAny],
|
|
1654
|
+
list: [Permissions.IsAny],
|
|
1655
|
+
read: [Permissions.IsAny],
|
|
1656
|
+
update: [Permissions.IsAny],
|
|
1657
|
+
},
|
|
1658
|
+
responseHandler: (_data, method) => {
|
|
1659
|
+
if (method === "read") {
|
|
1660
|
+
throw new Error("responseHandler read failed");
|
|
1661
|
+
}
|
|
1662
|
+
return {} as any;
|
|
1663
|
+
},
|
|
1664
|
+
})
|
|
1665
|
+
);
|
|
1666
|
+
server = supertest(app);
|
|
1667
|
+
|
|
1668
|
+
const res = await server.get(`/food/${spinach._id}`).expect(500);
|
|
1669
|
+
expect(res.body.title).toContain("responseHandler error");
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
it("responseHandler error in create is handled", async () => {
|
|
1673
|
+
app.use(
|
|
1674
|
+
"/food",
|
|
1675
|
+
modelRouter(FoodModel, {
|
|
1676
|
+
allowAnonymous: true,
|
|
1677
|
+
permissions: {
|
|
1678
|
+
create: [Permissions.IsAny],
|
|
1679
|
+
delete: [Permissions.IsAny],
|
|
1680
|
+
list: [Permissions.IsAny],
|
|
1681
|
+
read: [Permissions.IsAny],
|
|
1682
|
+
update: [Permissions.IsAny],
|
|
1683
|
+
},
|
|
1684
|
+
responseHandler: (_data, method) => {
|
|
1685
|
+
if (method === "create") {
|
|
1686
|
+
throw new Error("responseHandler create failed");
|
|
1687
|
+
}
|
|
1688
|
+
return {} as any;
|
|
1689
|
+
},
|
|
1690
|
+
})
|
|
1691
|
+
);
|
|
1692
|
+
server = supertest(app);
|
|
1693
|
+
|
|
1694
|
+
const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(500);
|
|
1695
|
+
expect(res.body.title).toContain("responseHandler error");
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it("responseHandler error in update is handled", async () => {
|
|
1699
|
+
app.use(
|
|
1700
|
+
"/food",
|
|
1701
|
+
modelRouter(FoodModel, {
|
|
1702
|
+
allowAnonymous: true,
|
|
1703
|
+
permissions: {
|
|
1704
|
+
create: [Permissions.IsAny],
|
|
1705
|
+
delete: [Permissions.IsAny],
|
|
1706
|
+
list: [Permissions.IsAny],
|
|
1707
|
+
read: [Permissions.IsAny],
|
|
1708
|
+
update: [Permissions.IsAny],
|
|
1709
|
+
},
|
|
1710
|
+
responseHandler: (_data, method) => {
|
|
1711
|
+
if (method === "update") {
|
|
1712
|
+
throw new Error("responseHandler update failed");
|
|
1713
|
+
}
|
|
1714
|
+
return {} as any;
|
|
1715
|
+
},
|
|
1716
|
+
})
|
|
1717
|
+
);
|
|
1718
|
+
server = supertest(app);
|
|
1719
|
+
|
|
1720
|
+
const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(500);
|
|
1721
|
+
expect(res.body.title).toContain("responseHandler error");
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
it("responseHandler error in list is handled", async () => {
|
|
1725
|
+
app.use(
|
|
1726
|
+
"/food",
|
|
1727
|
+
modelRouter(FoodModel, {
|
|
1728
|
+
allowAnonymous: true,
|
|
1729
|
+
permissions: {
|
|
1730
|
+
create: [Permissions.IsAny],
|
|
1731
|
+
delete: [Permissions.IsAny],
|
|
1732
|
+
list: [Permissions.IsAny],
|
|
1733
|
+
read: [Permissions.IsAny],
|
|
1734
|
+
update: [Permissions.IsAny],
|
|
1735
|
+
},
|
|
1736
|
+
responseHandler: (_data, method) => {
|
|
1737
|
+
if (method === "list") {
|
|
1738
|
+
throw new Error("responseHandler list failed");
|
|
1739
|
+
}
|
|
1740
|
+
return {} as any;
|
|
1741
|
+
},
|
|
1742
|
+
})
|
|
1743
|
+
);
|
|
1744
|
+
server = supertest(app);
|
|
1745
|
+
|
|
1746
|
+
const res = await server.get("/food").expect(500);
|
|
1747
|
+
expect(res.body.title).toContain("responseHandler error");
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
it("list with non-array responseHandler returns data directly", async () => {
|
|
1751
|
+
app.use(
|
|
1752
|
+
"/food",
|
|
1753
|
+
modelRouter(FoodModel, {
|
|
1754
|
+
allowAnonymous: true,
|
|
1755
|
+
permissions: {
|
|
1756
|
+
create: [Permissions.IsAny],
|
|
1757
|
+
delete: [Permissions.IsAny],
|
|
1758
|
+
list: [Permissions.IsAny],
|
|
1759
|
+
read: [Permissions.IsAny],
|
|
1760
|
+
update: [Permissions.IsAny],
|
|
1761
|
+
},
|
|
1762
|
+
responseHandler: (_data, method) => {
|
|
1763
|
+
if (method === "list") {
|
|
1764
|
+
return {custom: "response"} as any;
|
|
1765
|
+
}
|
|
1766
|
+
return {} as any;
|
|
1767
|
+
},
|
|
1768
|
+
})
|
|
1769
|
+
);
|
|
1770
|
+
server = supertest(app);
|
|
1771
|
+
|
|
1772
|
+
const res = await server.get("/food").expect(200);
|
|
1773
|
+
expect(res.body.data).toEqual({custom: "response"});
|
|
1774
|
+
expect(res.body.more).toBeUndefined();
|
|
1775
|
+
expect(res.body.total).toBeUndefined();
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
it("list with query sort param", async () => {
|
|
1779
|
+
await FoodModel.create({
|
|
1780
|
+
calories: 200,
|
|
1781
|
+
created: new Date("2021-12-04T00:00:20.000Z"),
|
|
1782
|
+
hidden: false,
|
|
1783
|
+
name: "Apple",
|
|
1784
|
+
ownerId: admin._id,
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
app.use(
|
|
1788
|
+
"/food",
|
|
1789
|
+
modelRouter(FoodModel, {
|
|
1790
|
+
allowAnonymous: true,
|
|
1791
|
+
permissions: {
|
|
1792
|
+
create: [Permissions.IsAny],
|
|
1793
|
+
delete: [Permissions.IsAny],
|
|
1794
|
+
list: [Permissions.IsAny],
|
|
1795
|
+
read: [Permissions.IsAny],
|
|
1796
|
+
update: [Permissions.IsAny],
|
|
1797
|
+
},
|
|
1798
|
+
queryFields: ["name"],
|
|
1799
|
+
})
|
|
1800
|
+
);
|
|
1801
|
+
server = supertest(app);
|
|
1802
|
+
|
|
1803
|
+
// Sort by name ascending
|
|
1804
|
+
let res = await server.get("/food?sort=name").expect(200);
|
|
1805
|
+
expect(res.body.data[0].name).toBe("Apple");
|
|
1806
|
+
expect(res.body.data[1].name).toBe("Spinach");
|
|
1807
|
+
|
|
1808
|
+
// Sort by name descending
|
|
1809
|
+
res = await server.get("/food?sort=-name").expect(200);
|
|
1810
|
+
expect(res.body.data[0].name).toBe("Spinach");
|
|
1811
|
+
expect(res.body.data[1].name).toBe("Apple");
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
it("queryFilter error is handled", async () => {
|
|
1815
|
+
app.use(
|
|
1816
|
+
"/food",
|
|
1817
|
+
modelRouter(FoodModel, {
|
|
1818
|
+
allowAnonymous: true,
|
|
1819
|
+
permissions: {
|
|
1820
|
+
create: [Permissions.IsAny],
|
|
1821
|
+
delete: [Permissions.IsAny],
|
|
1822
|
+
list: [Permissions.IsAny],
|
|
1823
|
+
read: [Permissions.IsAny],
|
|
1824
|
+
update: [Permissions.IsAny],
|
|
1825
|
+
},
|
|
1826
|
+
queryFilter: () => {
|
|
1827
|
+
throw new Error("queryFilter failed");
|
|
1828
|
+
},
|
|
1829
|
+
})
|
|
1830
|
+
);
|
|
1831
|
+
server = supertest(app);
|
|
1832
|
+
|
|
1833
|
+
const res = await server.get("/food").expect(400);
|
|
1834
|
+
expect(res.body.title).toContain("Query filter error");
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
it("custom endpoints take priority", async () => {
|
|
1838
|
+
app.use(
|
|
1839
|
+
"/food",
|
|
1840
|
+
modelRouter(FoodModel, {
|
|
1841
|
+
allowAnonymous: true,
|
|
1842
|
+
endpoints: (router: any) => {
|
|
1843
|
+
router.get("/custom", (_req: any, res: any) => {
|
|
1844
|
+
res.json({custom: true});
|
|
1845
|
+
});
|
|
1846
|
+
},
|
|
1847
|
+
permissions: {
|
|
1848
|
+
create: [Permissions.IsAny],
|
|
1849
|
+
delete: [Permissions.IsAny],
|
|
1850
|
+
list: [Permissions.IsAny],
|
|
1851
|
+
read: [Permissions.IsAny],
|
|
1852
|
+
update: [Permissions.IsAny],
|
|
1853
|
+
},
|
|
1854
|
+
})
|
|
1855
|
+
);
|
|
1856
|
+
server = supertest(app);
|
|
1857
|
+
|
|
1858
|
+
const res = await server.get("/food/custom").expect(200);
|
|
1859
|
+
expect(res.body.custom).toBe(true);
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
it("disallowed query param returns 400", async () => {
|
|
1863
|
+
app.use(
|
|
1864
|
+
"/food",
|
|
1865
|
+
modelRouter(FoodModel, {
|
|
1866
|
+
allowAnonymous: true,
|
|
1867
|
+
permissions: {
|
|
1868
|
+
create: [Permissions.IsAny],
|
|
1869
|
+
delete: [Permissions.IsAny],
|
|
1870
|
+
list: [Permissions.IsAny],
|
|
1871
|
+
read: [Permissions.IsAny],
|
|
1872
|
+
update: [Permissions.IsAny],
|
|
1873
|
+
},
|
|
1874
|
+
queryFields: ["name"],
|
|
1875
|
+
})
|
|
1876
|
+
);
|
|
1877
|
+
server = supertest(app);
|
|
1878
|
+
|
|
1879
|
+
const res = await server.get("/food?calories=100").expect(400);
|
|
1880
|
+
expect(res.body.title).toContain("calories is not allowed as a query param");
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
it("queryFilter returning null returns empty array", async () => {
|
|
1884
|
+
app.use(
|
|
1885
|
+
"/food",
|
|
1886
|
+
modelRouter(FoodModel, {
|
|
1887
|
+
allowAnonymous: true,
|
|
1888
|
+
permissions: {
|
|
1889
|
+
create: [Permissions.IsAny],
|
|
1890
|
+
delete: [Permissions.IsAny],
|
|
1891
|
+
list: [Permissions.IsAny],
|
|
1892
|
+
read: [Permissions.IsAny],
|
|
1893
|
+
update: [Permissions.IsAny],
|
|
1894
|
+
},
|
|
1895
|
+
queryFilter: () => null,
|
|
1896
|
+
})
|
|
1897
|
+
);
|
|
1898
|
+
server = supertest(app);
|
|
1899
|
+
|
|
1900
|
+
const res = await server.get("/food").expect(200);
|
|
1901
|
+
expect(res.body.data).toEqual([]);
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
it("preUpdate returning null throws error", async () => {
|
|
1905
|
+
app.use(
|
|
1906
|
+
"/food",
|
|
1907
|
+
modelRouter(FoodModel, {
|
|
1908
|
+
allowAnonymous: true,
|
|
1909
|
+
permissions: {
|
|
1910
|
+
create: [Permissions.IsAny],
|
|
1911
|
+
delete: [Permissions.IsAny],
|
|
1912
|
+
list: [Permissions.IsAny],
|
|
1913
|
+
read: [Permissions.IsAny],
|
|
1914
|
+
update: [Permissions.IsAny],
|
|
1915
|
+
},
|
|
1916
|
+
preUpdate: () => null,
|
|
1917
|
+
})
|
|
1918
|
+
);
|
|
1919
|
+
server = supertest(app);
|
|
1920
|
+
|
|
1921
|
+
const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(403);
|
|
1922
|
+
expect(res.body.title).toBe("Update not allowed");
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
it("preDelete returning null throws error", async () => {
|
|
1926
|
+
app.use(
|
|
1927
|
+
"/food",
|
|
1928
|
+
modelRouter(FoodModel, {
|
|
1929
|
+
allowAnonymous: true,
|
|
1930
|
+
permissions: {
|
|
1931
|
+
create: [Permissions.IsAny],
|
|
1932
|
+
delete: [Permissions.IsAny],
|
|
1933
|
+
list: [Permissions.IsAny],
|
|
1934
|
+
read: [Permissions.IsAny],
|
|
1935
|
+
update: [Permissions.IsAny],
|
|
1936
|
+
},
|
|
1937
|
+
preDelete: () => null,
|
|
1938
|
+
})
|
|
1939
|
+
);
|
|
1940
|
+
server = supertest(app);
|
|
1941
|
+
agent = await authAsUser(app, "notAdmin");
|
|
1942
|
+
|
|
1943
|
+
const res = await agent.delete(`/food/${spinach._id}`).expect(403);
|
|
1944
|
+
expect(res.body.title).toBe("Delete not allowed");
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
it("preCreate returning null throws error", async () => {
|
|
1948
|
+
app.use(
|
|
1949
|
+
"/food",
|
|
1950
|
+
modelRouter(FoodModel, {
|
|
1951
|
+
allowAnonymous: true,
|
|
1952
|
+
permissions: {
|
|
1953
|
+
create: [Permissions.IsAny],
|
|
1954
|
+
delete: [Permissions.IsAny],
|
|
1955
|
+
list: [Permissions.IsAny],
|
|
1956
|
+
read: [Permissions.IsAny],
|
|
1957
|
+
update: [Permissions.IsAny],
|
|
1958
|
+
},
|
|
1959
|
+
preCreate: () => null,
|
|
1960
|
+
})
|
|
1961
|
+
);
|
|
1962
|
+
server = supertest(app);
|
|
1963
|
+
|
|
1964
|
+
const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(403);
|
|
1965
|
+
expect(res.body.title).toBe("Create not allowed");
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
it("preCreate error is handled", async () => {
|
|
1969
|
+
app.use(
|
|
1970
|
+
"/food",
|
|
1971
|
+
modelRouter(FoodModel, {
|
|
1972
|
+
allowAnonymous: true,
|
|
1973
|
+
permissions: {
|
|
1974
|
+
create: [Permissions.IsAny],
|
|
1975
|
+
delete: [Permissions.IsAny],
|
|
1976
|
+
list: [Permissions.IsAny],
|
|
1977
|
+
read: [Permissions.IsAny],
|
|
1978
|
+
update: [Permissions.IsAny],
|
|
1979
|
+
},
|
|
1980
|
+
preCreate: () => {
|
|
1981
|
+
throw new Error("preCreate failed");
|
|
1982
|
+
},
|
|
1983
|
+
})
|
|
1984
|
+
);
|
|
1985
|
+
server = supertest(app);
|
|
1986
|
+
|
|
1987
|
+
const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
|
|
1988
|
+
expect(res.body.title).toContain("preCreate hook error");
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
it("preUpdate error is handled", async () => {
|
|
1992
|
+
app.use(
|
|
1993
|
+
"/food",
|
|
1994
|
+
modelRouter(FoodModel, {
|
|
1995
|
+
allowAnonymous: true,
|
|
1996
|
+
permissions: {
|
|
1997
|
+
create: [Permissions.IsAny],
|
|
1998
|
+
delete: [Permissions.IsAny],
|
|
1999
|
+
list: [Permissions.IsAny],
|
|
2000
|
+
read: [Permissions.IsAny],
|
|
2001
|
+
update: [Permissions.IsAny],
|
|
2002
|
+
},
|
|
2003
|
+
preUpdate: () => {
|
|
2004
|
+
throw new Error("preUpdate failed");
|
|
2005
|
+
},
|
|
2006
|
+
})
|
|
2007
|
+
);
|
|
2008
|
+
server = supertest(app);
|
|
2009
|
+
|
|
2010
|
+
const res = await server.patch(`/food/${spinach._id}`).send({name: "Kale"}).expect(400);
|
|
2011
|
+
expect(res.body.title).toContain("preUpdate hook error");
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
it("invalid array operation type returns 400", async () => {
|
|
2015
|
+
// This tests the else branch for invalid array operations
|
|
2016
|
+
// We need to manually call the endpoint with an invalid HTTP method
|
|
2017
|
+
// The array operations use POST (add), PATCH (update), DELETE (remove)
|
|
2018
|
+
// We can't easily test this without modifying the router, so skip for now
|
|
2019
|
+
});
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
describe("array operation errors", () => {
|
|
2023
|
+
let admin: any;
|
|
2024
|
+
let apple: Food;
|
|
2025
|
+
let agent: TestAgent;
|
|
2026
|
+
|
|
2027
|
+
beforeEach(async () => {
|
|
2028
|
+
[admin] = await setupDb();
|
|
2029
|
+
|
|
2030
|
+
apple = await FoodModel.create({
|
|
2031
|
+
calories: 100,
|
|
2032
|
+
categories: [
|
|
2033
|
+
{name: "Fruit", show: true},
|
|
2034
|
+
{name: "Popular", show: false},
|
|
2035
|
+
],
|
|
2036
|
+
created: new Date("2021-12-03T00:00:30.000Z"),
|
|
2037
|
+
hidden: false,
|
|
2038
|
+
name: "Apple",
|
|
2039
|
+
ownerId: admin._id,
|
|
2040
|
+
tags: ["healthy", "cheap"],
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
app = getBaseServer();
|
|
2044
|
+
setupAuth(app, UserModel as any);
|
|
2045
|
+
addAuthRoutes(app, UserModel as any);
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
it("array operation preUpdate returning undefined throws error", async () => {
|
|
2049
|
+
app.use(
|
|
2050
|
+
"/food",
|
|
2051
|
+
modelRouter(FoodModel, {
|
|
2052
|
+
allowAnonymous: true,
|
|
2053
|
+
permissions: {
|
|
2054
|
+
create: [Permissions.IsAdmin],
|
|
2055
|
+
delete: [Permissions.IsAdmin],
|
|
2056
|
+
list: [Permissions.IsAdmin],
|
|
2057
|
+
read: [Permissions.IsAdmin],
|
|
2058
|
+
update: [Permissions.IsAdmin],
|
|
2059
|
+
},
|
|
2060
|
+
preUpdate: () => undefined as any,
|
|
2061
|
+
})
|
|
2062
|
+
);
|
|
2063
|
+
server = supertest(app);
|
|
2064
|
+
agent = await authAsUser(app, "admin");
|
|
2065
|
+
|
|
2066
|
+
const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
|
|
2067
|
+
expect(res.body.title).toBe("Update not allowed");
|
|
2068
|
+
expect(res.body.detail).toBe("A body must be returned from preUpdate");
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
it("array operation preUpdate returning null throws error", async () => {
|
|
2072
|
+
app.use(
|
|
2073
|
+
"/food",
|
|
2074
|
+
modelRouter(FoodModel, {
|
|
2075
|
+
allowAnonymous: true,
|
|
2076
|
+
permissions: {
|
|
2077
|
+
create: [Permissions.IsAdmin],
|
|
2078
|
+
delete: [Permissions.IsAdmin],
|
|
2079
|
+
list: [Permissions.IsAdmin],
|
|
2080
|
+
read: [Permissions.IsAdmin],
|
|
2081
|
+
update: [Permissions.IsAdmin],
|
|
2082
|
+
},
|
|
2083
|
+
preUpdate: () => null,
|
|
2084
|
+
})
|
|
2085
|
+
);
|
|
2086
|
+
server = supertest(app);
|
|
2087
|
+
agent = await authAsUser(app, "admin");
|
|
2088
|
+
|
|
2089
|
+
const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
|
|
2090
|
+
expect(res.body.title).toBe("Update not allowed");
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it("array operation preUpdate error is handled", async () => {
|
|
2094
|
+
app.use(
|
|
2095
|
+
"/food",
|
|
2096
|
+
modelRouter(FoodModel, {
|
|
2097
|
+
allowAnonymous: true,
|
|
2098
|
+
permissions: {
|
|
2099
|
+
create: [Permissions.IsAdmin],
|
|
2100
|
+
delete: [Permissions.IsAdmin],
|
|
2101
|
+
list: [Permissions.IsAdmin],
|
|
2102
|
+
read: [Permissions.IsAdmin],
|
|
2103
|
+
update: [Permissions.IsAdmin],
|
|
2104
|
+
},
|
|
2105
|
+
preUpdate: () => {
|
|
2106
|
+
throw new Error("preUpdate array failed");
|
|
2107
|
+
},
|
|
2108
|
+
})
|
|
2109
|
+
);
|
|
2110
|
+
server = supertest(app);
|
|
2111
|
+
agent = await authAsUser(app, "admin");
|
|
2112
|
+
|
|
2113
|
+
const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(400);
|
|
2114
|
+
expect(res.body.title).toContain("preUpdate hook error");
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
it("array operation postUpdate error is handled", async () => {
|
|
2118
|
+
app.use(
|
|
2119
|
+
"/food",
|
|
2120
|
+
modelRouter(FoodModel, {
|
|
2121
|
+
allowAnonymous: true,
|
|
2122
|
+
permissions: {
|
|
2123
|
+
create: [Permissions.IsAdmin],
|
|
2124
|
+
delete: [Permissions.IsAdmin],
|
|
2125
|
+
list: [Permissions.IsAdmin],
|
|
2126
|
+
read: [Permissions.IsAdmin],
|
|
2127
|
+
update: [Permissions.IsAdmin],
|
|
2128
|
+
},
|
|
2129
|
+
postUpdate: () => {
|
|
2130
|
+
throw new Error("postUpdate array failed");
|
|
2131
|
+
},
|
|
2132
|
+
})
|
|
2133
|
+
);
|
|
2134
|
+
server = supertest(app);
|
|
2135
|
+
agent = await authAsUser(app, "admin");
|
|
2136
|
+
|
|
2137
|
+
const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(400);
|
|
2138
|
+
expect(res.body.title).toContain("PATCH Post Update error");
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
it("array operation denied without update permission", async () => {
|
|
2142
|
+
app.use(
|
|
2143
|
+
"/food",
|
|
2144
|
+
modelRouter(FoodModel, {
|
|
2145
|
+
allowAnonymous: true,
|
|
2146
|
+
permissions: {
|
|
2147
|
+
create: [Permissions.IsAdmin],
|
|
2148
|
+
delete: [Permissions.IsAdmin],
|
|
2149
|
+
list: [Permissions.IsAny],
|
|
2150
|
+
read: [Permissions.IsAny],
|
|
2151
|
+
update: [Permissions.IsAdmin],
|
|
2152
|
+
},
|
|
2153
|
+
})
|
|
2154
|
+
);
|
|
2155
|
+
server = supertest(app);
|
|
2156
|
+
agent = await authAsUser(app, "notAdmin");
|
|
2157
|
+
|
|
2158
|
+
const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(405);
|
|
2159
|
+
expect(res.body.title).toContain("Access to PATCH");
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
it("array operation on non-existent document returns 404", async () => {
|
|
2163
|
+
app.use(
|
|
2164
|
+
"/food",
|
|
2165
|
+
modelRouter(FoodModel, {
|
|
2166
|
+
allowAnonymous: true,
|
|
2167
|
+
permissions: {
|
|
2168
|
+
create: [Permissions.IsAdmin],
|
|
2169
|
+
delete: [Permissions.IsAdmin],
|
|
2170
|
+
list: [Permissions.IsAdmin],
|
|
2171
|
+
read: [Permissions.IsAdmin],
|
|
2172
|
+
update: [Permissions.IsAdmin],
|
|
2173
|
+
},
|
|
2174
|
+
})
|
|
2175
|
+
);
|
|
2176
|
+
server = supertest(app);
|
|
2177
|
+
agent = await authAsUser(app, "admin");
|
|
2178
|
+
|
|
2179
|
+
const fakeId = "000000000000000000000000";
|
|
2180
|
+
const res = await agent.post(`/food/${fakeId}/tags`).send({tags: "organic"}).expect(404);
|
|
2181
|
+
expect(res.body.title).toContain("Could not find document to PATCH");
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
it("array operation denied when user cannot update specific doc", async () => {
|
|
2185
|
+
// Create food owned by admin, then try to update as notAdmin
|
|
2186
|
+
app.use(
|
|
2187
|
+
"/food",
|
|
2188
|
+
modelRouter(FoodModel, {
|
|
2189
|
+
allowAnonymous: true,
|
|
2190
|
+
permissions: {
|
|
2191
|
+
create: [Permissions.IsAuthenticated],
|
|
2192
|
+
delete: [Permissions.IsAuthenticated],
|
|
2193
|
+
list: [Permissions.IsAuthenticated],
|
|
2194
|
+
read: [Permissions.IsAuthenticated],
|
|
2195
|
+
update: [Permissions.IsOwner],
|
|
2196
|
+
},
|
|
2197
|
+
})
|
|
2198
|
+
);
|
|
2199
|
+
server = supertest(app);
|
|
2200
|
+
// Login as notAdmin and try to update admin's food (apple)
|
|
2201
|
+
agent = await authAsUser(app, "notAdmin");
|
|
2202
|
+
|
|
2203
|
+
const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
|
|
2204
|
+
expect(res.body.title).toContain("Patch not allowed");
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it("array operation transform error is handled", async () => {
|
|
2208
|
+
app.use(
|
|
2209
|
+
"/food",
|
|
2210
|
+
modelRouter(FoodModel, {
|
|
2211
|
+
allowAnonymous: true,
|
|
2212
|
+
permissions: {
|
|
2213
|
+
create: [Permissions.IsAdmin],
|
|
2214
|
+
delete: [Permissions.IsAdmin],
|
|
2215
|
+
list: [Permissions.IsAdmin],
|
|
2216
|
+
read: [Permissions.IsAdmin],
|
|
2217
|
+
update: [Permissions.IsAdmin],
|
|
2218
|
+
},
|
|
2219
|
+
transformer: AdminOwnerTransformer({
|
|
2220
|
+
adminWriteFields: ["name"],
|
|
2221
|
+
}),
|
|
2222
|
+
})
|
|
2223
|
+
);
|
|
2224
|
+
server = supertest(app);
|
|
2225
|
+
agent = await authAsUser(app, "admin");
|
|
2226
|
+
|
|
2227
|
+
// Try to update tags field, which is not in the allowed write fields
|
|
2228
|
+
const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
|
|
2229
|
+
expect(res.body.title).toContain("cannot write fields");
|
|
2230
|
+
});
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
describe("transformer errors", () => {
|
|
2234
|
+
let admin: any;
|
|
2235
|
+
let spinach: Food;
|
|
2236
|
+
let agent: TestAgent;
|
|
2237
|
+
|
|
2238
|
+
beforeEach(async () => {
|
|
2239
|
+
[admin] = await setupDb();
|
|
2240
|
+
|
|
2241
|
+
spinach = await FoodModel.create({
|
|
2242
|
+
calories: 1,
|
|
2243
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
2244
|
+
hidden: false,
|
|
2245
|
+
name: "Spinach",
|
|
2246
|
+
ownerId: admin._id,
|
|
2247
|
+
source: {
|
|
2248
|
+
name: "Brand",
|
|
2249
|
+
},
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
app = getBaseServer();
|
|
2253
|
+
setupAuth(app, UserModel as any);
|
|
2254
|
+
addAuthRoutes(app, UserModel as any);
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
it("transform error in create is handled", async () => {
|
|
2258
|
+
app.use(
|
|
2259
|
+
"/food",
|
|
2260
|
+
modelRouter(FoodModel, {
|
|
2261
|
+
allowAnonymous: true,
|
|
2262
|
+
permissions: {
|
|
2263
|
+
create: [Permissions.IsAny],
|
|
2264
|
+
delete: [Permissions.IsAny],
|
|
2265
|
+
list: [Permissions.IsAny],
|
|
2266
|
+
read: [Permissions.IsAny],
|
|
2267
|
+
update: [Permissions.IsAny],
|
|
2268
|
+
},
|
|
2269
|
+
transformer: AdminOwnerTransformer({
|
|
2270
|
+
// Only allow 'name' to be written, so 'calories' will throw
|
|
2271
|
+
anonWriteFields: ["name"],
|
|
2272
|
+
}),
|
|
2273
|
+
})
|
|
2274
|
+
);
|
|
2275
|
+
server = supertest(app);
|
|
2276
|
+
|
|
2277
|
+
const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
|
|
2278
|
+
expect(res.body.title).toContain("cannot write fields");
|
|
2279
|
+
});
|
|
2280
|
+
|
|
2281
|
+
it("transform error in patch is handled", async () => {
|
|
2282
|
+
app.use(
|
|
2283
|
+
"/food",
|
|
2284
|
+
modelRouter(FoodModel, {
|
|
2285
|
+
allowAnonymous: true,
|
|
2286
|
+
permissions: {
|
|
2287
|
+
create: [Permissions.IsAny],
|
|
2288
|
+
delete: [Permissions.IsAny],
|
|
2289
|
+
list: [Permissions.IsAny],
|
|
2290
|
+
read: [Permissions.IsAny],
|
|
2291
|
+
update: [Permissions.IsAny],
|
|
2292
|
+
},
|
|
2293
|
+
transformer: AdminOwnerTransformer({
|
|
2294
|
+
// Only allow 'name' to be written, so 'calories' will throw
|
|
2295
|
+
anonWriteFields: ["name"],
|
|
2296
|
+
}),
|
|
2297
|
+
})
|
|
2298
|
+
);
|
|
2299
|
+
server = supertest(app);
|
|
2300
|
+
|
|
2301
|
+
const res = await server.patch(`/food/${spinach._id}`).send({calories: 100}).expect(403);
|
|
2302
|
+
expect(res.body.title).toContain("cannot write fields");
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
it("model.create validation error is handled", async () => {
|
|
2306
|
+
// Use a model that has required fields
|
|
2307
|
+
const {RequiredModel} = await import("./tests");
|
|
2308
|
+
|
|
2309
|
+
app.use(
|
|
2310
|
+
"/required",
|
|
2311
|
+
modelRouter(RequiredModel, {
|
|
2312
|
+
allowAnonymous: true,
|
|
2313
|
+
permissions: {
|
|
2314
|
+
create: [Permissions.IsAny],
|
|
2315
|
+
delete: [Permissions.IsAny],
|
|
2316
|
+
list: [Permissions.IsAny],
|
|
2317
|
+
read: [Permissions.IsAny],
|
|
2318
|
+
update: [Permissions.IsAny],
|
|
2319
|
+
},
|
|
2320
|
+
})
|
|
2321
|
+
);
|
|
2322
|
+
server = supertest(app);
|
|
2323
|
+
|
|
2324
|
+
// Send without required 'name' field
|
|
2325
|
+
const res = await server.post("/required").send({about: "test"}).expect(400);
|
|
2326
|
+
expect(res.body.title).toContain("Required");
|
|
2327
|
+
});
|
|
2328
|
+
|
|
2329
|
+
it("preDelete hook throwing APIError is re-thrown", async () => {
|
|
2330
|
+
app.use(
|
|
2331
|
+
"/food",
|
|
2332
|
+
modelRouter(FoodModel, {
|
|
2333
|
+
allowAnonymous: true,
|
|
2334
|
+
permissions: {
|
|
2335
|
+
create: [Permissions.IsAny],
|
|
2336
|
+
delete: [Permissions.IsAny],
|
|
2337
|
+
list: [Permissions.IsAny],
|
|
2338
|
+
read: [Permissions.IsAny],
|
|
2339
|
+
update: [Permissions.IsAny],
|
|
2340
|
+
},
|
|
2341
|
+
preDelete: () => {
|
|
2342
|
+
throw new APIError({
|
|
2343
|
+
disableExternalErrorTracking: true,
|
|
2344
|
+
status: 400,
|
|
2345
|
+
title: "Custom preDelete APIError",
|
|
2346
|
+
});
|
|
2347
|
+
},
|
|
2348
|
+
})
|
|
2349
|
+
);
|
|
2350
|
+
server = supertest(app);
|
|
2351
|
+
agent = await authAsUser(app, "notAdmin");
|
|
2352
|
+
|
|
2353
|
+
const res = await agent.delete(`/food/${spinach._id}`).expect(400);
|
|
2354
|
+
expect(res.body.title).toBe("Custom preDelete APIError");
|
|
2355
|
+
expect(res.body.disableExternalErrorTracking).toBe(true);
|
|
2356
|
+
});
|
|
2357
|
+
});
|
|
2358
|
+
|
|
2359
|
+
describe("special query params", () => {
|
|
2360
|
+
let admin: any;
|
|
2361
|
+
|
|
2362
|
+
beforeEach(async () => {
|
|
2363
|
+
[admin] = await setupDb();
|
|
2364
|
+
|
|
2365
|
+
await FoodModel.create({
|
|
2366
|
+
calories: 1,
|
|
2367
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
2368
|
+
hidden: false,
|
|
2369
|
+
name: "Spinach",
|
|
2370
|
+
ownerId: admin._id,
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
app = getBaseServer();
|
|
2374
|
+
setupAuth(app, UserModel as any);
|
|
2375
|
+
addAuthRoutes(app, UserModel as any);
|
|
2376
|
+
});
|
|
2377
|
+
|
|
2378
|
+
it("period query param is stripped from query", async () => {
|
|
2379
|
+
app.use(
|
|
2380
|
+
"/food",
|
|
2381
|
+
modelRouter(FoodModel, {
|
|
2382
|
+
allowAnonymous: true,
|
|
2383
|
+
permissions: {
|
|
2384
|
+
create: [Permissions.IsAny],
|
|
2385
|
+
delete: [Permissions.IsAny],
|
|
2386
|
+
list: [Permissions.IsAny],
|
|
2387
|
+
read: [Permissions.IsAny],
|
|
2388
|
+
update: [Permissions.IsAny],
|
|
2389
|
+
},
|
|
2390
|
+
queryFields: ["name", "period"],
|
|
2391
|
+
queryFilter: (_user, query) => {
|
|
2392
|
+
// Simulate a queryFilter that accepts and processes period
|
|
2393
|
+
if (query?.period) {
|
|
2394
|
+
// Period is processed but shouldn't be passed to mongo
|
|
2395
|
+
return query;
|
|
2396
|
+
}
|
|
2397
|
+
return query ?? {};
|
|
2398
|
+
},
|
|
2399
|
+
})
|
|
2400
|
+
);
|
|
2401
|
+
server = supertest(app);
|
|
2402
|
+
|
|
2403
|
+
// period should be accepted and processed without error
|
|
2404
|
+
const res = await server.get("/food?period=weekly").expect(200);
|
|
2405
|
+
expect(res.body.data).toBeDefined();
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
it("query with false value", async () => {
|
|
2409
|
+
// Create a food that is hidden
|
|
2410
|
+
await FoodModel.create({
|
|
2411
|
+
calories: 50,
|
|
2412
|
+
created: new Date("2021-12-04T00:00:20.000Z"),
|
|
2413
|
+
hidden: true,
|
|
2414
|
+
name: "HiddenFood",
|
|
2415
|
+
ownerId: admin._id,
|
|
2416
|
+
});
|
|
2417
|
+
|
|
2418
|
+
app.use(
|
|
2419
|
+
"/food",
|
|
2420
|
+
modelRouter(FoodModel, {
|
|
2421
|
+
allowAnonymous: true,
|
|
2422
|
+
permissions: {
|
|
2423
|
+
create: [Permissions.IsAny],
|
|
2424
|
+
delete: [Permissions.IsAny],
|
|
2425
|
+
list: [Permissions.IsAny],
|
|
2426
|
+
read: [Permissions.IsAny],
|
|
2427
|
+
update: [Permissions.IsAny],
|
|
2428
|
+
},
|
|
2429
|
+
queryFields: ["name", "hidden"],
|
|
2430
|
+
})
|
|
2431
|
+
);
|
|
2432
|
+
server = supertest(app);
|
|
2433
|
+
|
|
2434
|
+
// Query for non-hidden foods using ?hidden=false
|
|
2435
|
+
const res = await server.get("/food?hidden=false").expect(200);
|
|
2436
|
+
expect(res.body.data.every((f: any) => f.hidden === false)).toBe(true);
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
it("$search query triggers special handling code path", async () => {
|
|
2440
|
+
// The $search code path just accesses the collection but doesn't do anything with it
|
|
2441
|
+
// This test verifies the code path is exercised
|
|
2442
|
+
app.use(
|
|
2443
|
+
"/food",
|
|
2444
|
+
modelRouter(FoodModel, {
|
|
2445
|
+
allowAnonymous: true,
|
|
2446
|
+
permissions: {
|
|
2447
|
+
create: [Permissions.IsAny],
|
|
2448
|
+
delete: [Permissions.IsAny],
|
|
2449
|
+
list: [Permissions.IsAny],
|
|
2450
|
+
read: [Permissions.IsAny],
|
|
2451
|
+
update: [Permissions.IsAny],
|
|
2452
|
+
},
|
|
2453
|
+
// Need to include $search in queryFields for it to pass validation
|
|
2454
|
+
queryFields: ["name", "$search"],
|
|
2455
|
+
})
|
|
2456
|
+
);
|
|
2457
|
+
server = supertest(app);
|
|
2458
|
+
|
|
2459
|
+
// The $search will be added to the query params, triggering the special handling
|
|
2460
|
+
// Even though the code doesn't actually do anything useful with it (stub for Atlas)
|
|
2461
|
+
const res = await server.get("/food?$search=test");
|
|
2462
|
+
// May return 500 because $search is passed to Mongo which doesn't support it without Atlas
|
|
2463
|
+
// The important thing is we've exercised the code path
|
|
2464
|
+
expect(res.status === 200 || res.status === 500).toBe(true);
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
it("$autocomplete query triggers special handling code path", async () => {
|
|
2468
|
+
app.use(
|
|
2469
|
+
"/food",
|
|
2470
|
+
modelRouter(FoodModel, {
|
|
2471
|
+
allowAnonymous: true,
|
|
2472
|
+
permissions: {
|
|
2473
|
+
create: [Permissions.IsAny],
|
|
2474
|
+
delete: [Permissions.IsAny],
|
|
2475
|
+
list: [Permissions.IsAny],
|
|
2476
|
+
read: [Permissions.IsAny],
|
|
2477
|
+
update: [Permissions.IsAny],
|
|
2478
|
+
},
|
|
2479
|
+
queryFields: ["name", "$autocomplete"],
|
|
2480
|
+
})
|
|
2481
|
+
);
|
|
2482
|
+
server = supertest(app);
|
|
2483
|
+
|
|
2484
|
+
const res = await server.get("/food?$autocomplete=test");
|
|
2485
|
+
expect(res.status === 200 || res.status === 500).toBe(true);
|
|
2486
|
+
});
|
|
2487
|
+
});
|
|
2488
|
+
|
|
2489
|
+
describe("addPopulateToQuery", () => {
|
|
2490
|
+
it("returns query unchanged with no populate paths", async () => {
|
|
2491
|
+
await setupDb();
|
|
2492
|
+
const query = FoodModel.find({});
|
|
2493
|
+
const result = addPopulateToQuery(query, undefined);
|
|
2494
|
+
expect(result).toBe(query);
|
|
2495
|
+
});
|
|
2496
|
+
|
|
2497
|
+
it("returns query unchanged with empty populate paths", async () => {
|
|
2498
|
+
await setupDb();
|
|
2499
|
+
const query = FoodModel.find({});
|
|
2500
|
+
const result = addPopulateToQuery(query, []);
|
|
2501
|
+
expect(result).toBe(query);
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
it("applies multiple populate paths", async () => {
|
|
2505
|
+
await setupDb();
|
|
2506
|
+
const query = FoodModel.find({});
|
|
2507
|
+
const result = addPopulateToQuery(query, [
|
|
2508
|
+
{fields: ["email"], path: "ownerId"},
|
|
2509
|
+
{fields: ["name"], path: "eatenBy"},
|
|
2510
|
+
]);
|
|
2511
|
+
// The result should be a query with populate applied
|
|
2512
|
+
expect(result).toBeDefined();
|
|
2513
|
+
});
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
describe("soft delete with isDeleted plugin", () => {
|
|
2517
|
+
let admin: any;
|
|
2518
|
+
let agent: TestAgent;
|
|
2519
|
+
|
|
2520
|
+
beforeEach(async () => {
|
|
2521
|
+
[admin] = await setupDb();
|
|
2522
|
+
|
|
2523
|
+
app = getBaseServer();
|
|
2524
|
+
setupAuth(app, UserModel as any);
|
|
2525
|
+
addAuthRoutes(app, UserModel as any);
|
|
2526
|
+
});
|
|
2527
|
+
|
|
2528
|
+
it("soft deletes user with deleted field", async () => {
|
|
2529
|
+
// UserModel has the isDisabledPlugin which adds a 'disabled' field,
|
|
2530
|
+
// but we need to test the 'deleted' field check.
|
|
2531
|
+
// Let's use a model that has the deleted field.
|
|
2532
|
+
app.use(
|
|
2533
|
+
"/users",
|
|
2534
|
+
modelRouter(UserModel, {
|
|
2535
|
+
allowAnonymous: true,
|
|
2536
|
+
permissions: {
|
|
2537
|
+
create: [Permissions.IsAny],
|
|
2538
|
+
delete: [Permissions.IsAny],
|
|
2539
|
+
list: [Permissions.IsAny],
|
|
2540
|
+
read: [Permissions.IsAny],
|
|
2541
|
+
update: [Permissions.IsAny],
|
|
2542
|
+
},
|
|
2543
|
+
})
|
|
2544
|
+
);
|
|
2545
|
+
server = supertest(app);
|
|
2546
|
+
agent = await authAsUser(app, "notAdmin");
|
|
2547
|
+
|
|
2548
|
+
// Delete a user - this should use deleteOne since User doesn't have deleted field
|
|
2549
|
+
const res = await agent.delete(`/users/${admin._id}`).expect(204);
|
|
2550
|
+
expect(res.body).toEqual({});
|
|
2551
|
+
|
|
2552
|
+
// Verify user was deleted
|
|
2553
|
+
const deletedUser = await UserModel.findById(admin._id);
|
|
2554
|
+
expect(deletedUser).toBeNull();
|
|
2555
|
+
});
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
describe("populate in create", () => {
|
|
2559
|
+
let admin: any;
|
|
2560
|
+
|
|
2561
|
+
beforeEach(async () => {
|
|
2562
|
+
[admin] = await setupDb();
|
|
2563
|
+
|
|
2564
|
+
await FoodModel.create({
|
|
2565
|
+
calories: 1,
|
|
2566
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
2567
|
+
hidden: false,
|
|
2568
|
+
name: "Spinach",
|
|
2569
|
+
ownerId: admin._id,
|
|
2570
|
+
});
|
|
2571
|
+
|
|
2572
|
+
app = getBaseServer();
|
|
2573
|
+
setupAuth(app, UserModel as any);
|
|
2574
|
+
addAuthRoutes(app, UserModel as any);
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
it("handles populate with valid path in create", async () => {
|
|
2578
|
+
// Test that valid populate works in create flow
|
|
2579
|
+
app.use(
|
|
2580
|
+
"/food",
|
|
2581
|
+
modelRouter(FoodModel, {
|
|
2582
|
+
allowAnonymous: true,
|
|
2583
|
+
permissions: {
|
|
2584
|
+
create: [Permissions.IsAny],
|
|
2585
|
+
delete: [Permissions.IsAny],
|
|
2586
|
+
list: [Permissions.IsAny],
|
|
2587
|
+
read: [Permissions.IsAny],
|
|
2588
|
+
update: [Permissions.IsAny],
|
|
2589
|
+
},
|
|
2590
|
+
populatePaths: [{fields: ["email"], path: "ownerId"}],
|
|
2591
|
+
})
|
|
2592
|
+
);
|
|
2593
|
+
server = supertest(app);
|
|
2594
|
+
|
|
2595
|
+
const res = await server
|
|
2596
|
+
.post("/food")
|
|
2597
|
+
.send({calories: 15, name: "Broccoli", ownerId: admin._id})
|
|
2598
|
+
.expect(201);
|
|
2599
|
+
expect(res.body.data.name).toBe("Broccoli");
|
|
2600
|
+
// Verify populate worked - ownerId should be an object with email
|
|
2601
|
+
expect(res.body.data.ownerId.email).toBe(admin.email);
|
|
2602
|
+
});
|
|
2603
|
+
});
|
|
2604
|
+
|
|
2605
|
+
describe("save error handling", () => {
|
|
2606
|
+
let admin: any;
|
|
2607
|
+
let spinach: Food;
|
|
2608
|
+
|
|
2609
|
+
beforeEach(async () => {
|
|
2610
|
+
[admin] = await setupDb();
|
|
2611
|
+
|
|
2612
|
+
spinach = await FoodModel.create({
|
|
2613
|
+
calories: 1,
|
|
2614
|
+
created: new Date("2021-12-03T00:00:20.000Z"),
|
|
2615
|
+
hidden: false,
|
|
2616
|
+
name: "Spinach",
|
|
2617
|
+
ownerId: admin._id,
|
|
2618
|
+
source: {
|
|
2619
|
+
name: "Brand",
|
|
2620
|
+
},
|
|
2621
|
+
});
|
|
2622
|
+
|
|
2623
|
+
app = getBaseServer();
|
|
2624
|
+
setupAuth(app, UserModel as any);
|
|
2625
|
+
addAuthRoutes(app, UserModel as any);
|
|
2626
|
+
});
|
|
2627
|
+
|
|
2628
|
+
it("handles patch save error with validation failure", async () => {
|
|
2629
|
+
// The FoodModel has strict: "throw" which will cause validation errors for unknown fields
|
|
2630
|
+
app.use(
|
|
2631
|
+
"/food",
|
|
2632
|
+
modelRouter(FoodModel, {
|
|
2633
|
+
allowAnonymous: true,
|
|
2634
|
+
permissions: {
|
|
2635
|
+
create: [Permissions.IsAny],
|
|
2636
|
+
delete: [Permissions.IsAny],
|
|
2637
|
+
list: [Permissions.IsAny],
|
|
2638
|
+
read: [Permissions.IsAny],
|
|
2639
|
+
update: [Permissions.IsAny],
|
|
2640
|
+
},
|
|
2641
|
+
})
|
|
2642
|
+
);
|
|
2643
|
+
server = supertest(app);
|
|
2644
|
+
|
|
2645
|
+
// Try to patch with an invalid field (will be caught by strict: "throw")
|
|
2646
|
+
const res = await server
|
|
2647
|
+
.patch(`/food/${spinach._id}`)
|
|
2648
|
+
.send({invalidField: "value"})
|
|
2649
|
+
.expect(400);
|
|
2650
|
+
expect(res.body.title).toContain("preUpdate hook save error");
|
|
2651
|
+
});
|
|
2652
|
+
});
|
|
2653
|
+
|
|
2654
|
+
describe("body undefined after transform without preCreate", () => {
|
|
2655
|
+
beforeEach(async () => {
|
|
2656
|
+
await setupDb();
|
|
2657
|
+
|
|
2658
|
+
app = getBaseServer();
|
|
2659
|
+
setupAuth(app, UserModel as any);
|
|
2660
|
+
addAuthRoutes(app, UserModel as any);
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
it("handles undefined body after transform when no preCreate", async () => {
|
|
2664
|
+
// Create a transformer that returns undefined
|
|
2665
|
+
app.use(
|
|
2666
|
+
"/food",
|
|
2667
|
+
modelRouter(FoodModel, {
|
|
2668
|
+
allowAnonymous: true,
|
|
2669
|
+
permissions: {
|
|
2670
|
+
create: [Permissions.IsAny],
|
|
2671
|
+
delete: [Permissions.IsAny],
|
|
2672
|
+
list: [Permissions.IsAny],
|
|
2673
|
+
read: [Permissions.IsAny],
|
|
2674
|
+
update: [Permissions.IsAny],
|
|
2675
|
+
},
|
|
2676
|
+
transformer: {
|
|
2677
|
+
transform: () => undefined,
|
|
2678
|
+
},
|
|
2679
|
+
})
|
|
2680
|
+
);
|
|
2681
|
+
server = supertest(app);
|
|
2682
|
+
|
|
2683
|
+
const res = await server.post("/food").send({calories: 15, name: "Broccoli"}).expect(400);
|
|
2684
|
+
expect(res.body.title).toBe("Invalid request body");
|
|
2685
|
+
expect(res.body.detail).toBe("Body is undefined");
|
|
2686
|
+
});
|
|
2687
|
+
});
|
|
2688
|
+
|
|
2689
|
+
describe("soft delete with deleted field", () => {
|
|
2690
|
+
let _admin: any;
|
|
1481
2691
|
let agent: TestAgent;
|
|
1482
2692
|
|
|
1483
2693
|
beforeEach(async () => {
|
|
1484
|
-
[
|
|
1485
|
-
const [staffUserId, superUserId] = await Promise.all([
|
|
1486
|
-
StaffUserModel.create({
|
|
1487
|
-
department: "Accounting",
|
|
1488
|
-
email: "staff@example.com",
|
|
1489
|
-
}),
|
|
1490
|
-
SuperUserModel.create({
|
|
1491
|
-
email: "superuser@example.com",
|
|
1492
|
-
superTitle: "Super Man",
|
|
1493
|
-
}),
|
|
1494
|
-
]);
|
|
1495
|
-
staffUser = (await UserModel.findById(staffUserId)) as any;
|
|
1496
|
-
superUser = (await UserModel.findById(superUserId)) as any;
|
|
2694
|
+
[_admin] = await setupDb();
|
|
1497
2695
|
|
|
1498
2696
|
app = getBaseServer();
|
|
1499
2697
|
setupAuth(app, UserModel as any);
|
|
1500
2698
|
addAuthRoutes(app, UserModel as any);
|
|
2699
|
+
});
|
|
2700
|
+
|
|
2701
|
+
it("soft deletes document with deleted field using isDeletedPlugin", async () => {
|
|
2702
|
+
// Create a test schema with the isDeletedPlugin
|
|
2703
|
+
const mongoose = await import("mongoose");
|
|
2704
|
+
|
|
2705
|
+
// Create a temporary model with the deleted field
|
|
2706
|
+
const softDeleteSchema = new mongoose.Schema({
|
|
2707
|
+
deleted: {default: false, type: Boolean},
|
|
2708
|
+
name: String,
|
|
2709
|
+
});
|
|
2710
|
+
// Manually add the deleted field (simulating what isDeletedPlugin does)
|
|
2711
|
+
// The schema already has the deleted field, so it should use soft delete
|
|
2712
|
+
|
|
2713
|
+
// Check if the model already exists to avoid OverwriteModelError
|
|
2714
|
+
let SoftDeleteModel;
|
|
2715
|
+
try {
|
|
2716
|
+
SoftDeleteModel = mongoose.model("SoftDeleteTest");
|
|
2717
|
+
} catch {
|
|
2718
|
+
SoftDeleteModel = mongoose.model("SoftDeleteTest", softDeleteSchema);
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// Clean up any existing documents
|
|
2722
|
+
await SoftDeleteModel.deleteMany({});
|
|
2723
|
+
|
|
2724
|
+
// Create a test document
|
|
2725
|
+
const testDoc = await SoftDeleteModel.create({name: "TestItem"});
|
|
2726
|
+
|
|
1501
2727
|
app.use(
|
|
1502
|
-
"/
|
|
1503
|
-
modelRouter(
|
|
2728
|
+
"/softdelete",
|
|
2729
|
+
modelRouter(SoftDeleteModel, {
|
|
1504
2730
|
allowAnonymous: true,
|
|
1505
|
-
discriminatorKey: "__t",
|
|
1506
2731
|
permissions: {
|
|
1507
|
-
create: [Permissions.
|
|
1508
|
-
delete: [Permissions.
|
|
1509
|
-
list: [Permissions.
|
|
1510
|
-
read: [Permissions.
|
|
1511
|
-
update: [Permissions.
|
|
2732
|
+
create: [Permissions.IsAny],
|
|
2733
|
+
delete: [Permissions.IsAny],
|
|
2734
|
+
list: [Permissions.IsAny],
|
|
2735
|
+
read: [Permissions.IsAny],
|
|
2736
|
+
update: [Permissions.IsAny],
|
|
1512
2737
|
},
|
|
1513
2738
|
})
|
|
1514
2739
|
);
|
|
2740
|
+
server = supertest(app);
|
|
2741
|
+
agent = await authAsUser(app, "notAdmin");
|
|
2742
|
+
|
|
2743
|
+
// Delete should soft delete (set deleted: true) instead of hard delete
|
|
2744
|
+
await agent.delete(`/softdelete/${testDoc._id}`).expect(204);
|
|
2745
|
+
|
|
2746
|
+
// Verify document was soft deleted (not hard deleted)
|
|
2747
|
+
const softDeleted = await SoftDeleteModel.findById(testDoc._id);
|
|
2748
|
+
expect(softDeleted).not.toBeNull();
|
|
2749
|
+
expect(softDeleted?.deleted).toBe(true);
|
|
2750
|
+
|
|
2751
|
+
// Clean up
|
|
2752
|
+
await SoftDeleteModel.deleteMany({});
|
|
2753
|
+
});
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
describe("array operation with undefined preUpdate return", () => {
|
|
2757
|
+
let admin: any;
|
|
2758
|
+
let apple: Food;
|
|
2759
|
+
let agent: TestAgent;
|
|
2760
|
+
|
|
2761
|
+
beforeEach(async () => {
|
|
2762
|
+
[admin] = await setupDb();
|
|
2763
|
+
|
|
2764
|
+
apple = await FoodModel.create({
|
|
2765
|
+
calories: 100,
|
|
2766
|
+
categories: [
|
|
2767
|
+
{name: "Fruit", show: true},
|
|
2768
|
+
{name: "Popular", show: false},
|
|
2769
|
+
],
|
|
2770
|
+
created: new Date("2021-12-03T00:00:30.000Z"),
|
|
2771
|
+
hidden: false,
|
|
2772
|
+
name: "Apple",
|
|
2773
|
+
ownerId: admin._id,
|
|
2774
|
+
tags: ["healthy", "cheap"],
|
|
2775
|
+
});
|
|
1515
2776
|
|
|
2777
|
+
app = getBaseServer();
|
|
2778
|
+
setupAuth(app, UserModel as any);
|
|
2779
|
+
addAuthRoutes(app, UserModel as any);
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
it("array operation preUpdate returning undefined for array POST throws error", async () => {
|
|
2783
|
+
app.use(
|
|
2784
|
+
"/food",
|
|
2785
|
+
modelRouter(FoodModel, {
|
|
2786
|
+
allowAnonymous: true,
|
|
2787
|
+
permissions: {
|
|
2788
|
+
create: [Permissions.IsAdmin],
|
|
2789
|
+
delete: [Permissions.IsAdmin],
|
|
2790
|
+
list: [Permissions.IsAdmin],
|
|
2791
|
+
read: [Permissions.IsAdmin],
|
|
2792
|
+
update: [Permissions.IsAdmin],
|
|
2793
|
+
},
|
|
2794
|
+
preUpdate: () => undefined as any,
|
|
2795
|
+
})
|
|
2796
|
+
);
|
|
1516
2797
|
server = supertest(app);
|
|
2798
|
+
agent = await authAsUser(app, "admin");
|
|
1517
2799
|
|
|
1518
|
-
|
|
2800
|
+
const res = await agent.post(`/food/${apple._id}/tags`).send({tags: "organic"}).expect(403);
|
|
2801
|
+
expect(res.body.title).toBe("Update not allowed");
|
|
2802
|
+
expect(res.body.detail).toBe("A body must be returned from preUpdate");
|
|
1519
2803
|
});
|
|
1520
2804
|
|
|
1521
|
-
it("
|
|
1522
|
-
|
|
1523
|
-
|
|
2805
|
+
it("array operation preUpdate returning null for array PATCH throws error", async () => {
|
|
2806
|
+
app.use(
|
|
2807
|
+
"/food",
|
|
2808
|
+
modelRouter(FoodModel, {
|
|
2809
|
+
allowAnonymous: true,
|
|
2810
|
+
permissions: {
|
|
2811
|
+
create: [Permissions.IsAdmin],
|
|
2812
|
+
delete: [Permissions.IsAdmin],
|
|
2813
|
+
list: [Permissions.IsAdmin],
|
|
2814
|
+
read: [Permissions.IsAdmin],
|
|
2815
|
+
update: [Permissions.IsAdmin],
|
|
2816
|
+
},
|
|
2817
|
+
preUpdate: () => null,
|
|
2818
|
+
})
|
|
2819
|
+
);
|
|
2820
|
+
server = supertest(app);
|
|
2821
|
+
agent = await authAsUser(app, "admin");
|
|
1524
2822
|
|
|
1525
|
-
const
|
|
2823
|
+
const res = await agent
|
|
2824
|
+
.patch(`/food/${apple._id}/tags/healthy`)
|
|
2825
|
+
.send({tags: "unhealthy"})
|
|
2826
|
+
.expect(403);
|
|
2827
|
+
expect(res.body.title).toBe("Update not allowed");
|
|
2828
|
+
});
|
|
1526
2829
|
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
2830
|
+
it("array operation preUpdate error for array DELETE is handled", async () => {
|
|
2831
|
+
app.use(
|
|
2832
|
+
"/food",
|
|
2833
|
+
modelRouter(FoodModel, {
|
|
2834
|
+
allowAnonymous: true,
|
|
2835
|
+
permissions: {
|
|
2836
|
+
create: [Permissions.IsAdmin],
|
|
2837
|
+
delete: [Permissions.IsAdmin],
|
|
2838
|
+
list: [Permissions.IsAdmin],
|
|
2839
|
+
read: [Permissions.IsAdmin],
|
|
2840
|
+
update: [Permissions.IsAdmin],
|
|
2841
|
+
},
|
|
2842
|
+
preUpdate: () => {
|
|
2843
|
+
throw new Error("preUpdate error during delete");
|
|
2844
|
+
},
|
|
2845
|
+
})
|
|
2846
|
+
);
|
|
2847
|
+
server = supertest(app);
|
|
2848
|
+
agent = await authAsUser(app, "admin");
|
|
1531
2849
|
|
|
1532
|
-
|
|
1533
|
-
expect(
|
|
1534
|
-
|
|
1535
|
-
|
|
2850
|
+
const res = await agent.delete(`/food/${apple._id}/tags/healthy`).expect(400);
|
|
2851
|
+
expect(res.body.title).toContain("preUpdate hook error");
|
|
2852
|
+
});
|
|
2853
|
+
});
|
|
2854
|
+
});
|
|
1536
2855
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
2856
|
+
describe("errors module", () => {
|
|
2857
|
+
describe("APIError", () => {
|
|
2858
|
+
it("sets default status to 500 when not provided", () => {
|
|
2859
|
+
const error = new APIError({title: "Test error"});
|
|
2860
|
+
expect(error.status).toBe(500);
|
|
2861
|
+
});
|
|
1541
2862
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
expect(
|
|
1545
|
-
|
|
2863
|
+
it("sets status to 500 for invalid status codes below 400", () => {
|
|
2864
|
+
const error = new APIError({status: 200, title: "Test error"});
|
|
2865
|
+
expect(error.status).toBe(500);
|
|
2866
|
+
});
|
|
1546
2867
|
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
expect(
|
|
1550
|
-
expect(data[4].__t).toBe("SuperUser");
|
|
2868
|
+
it("sets status to 500 for invalid status codes above 599", () => {
|
|
2869
|
+
const error = new APIError({status: 600, title: "Test error"});
|
|
2870
|
+
expect(error.status).toBe(500);
|
|
1551
2871
|
});
|
|
1552
2872
|
|
|
1553
|
-
it("
|
|
1554
|
-
const
|
|
2873
|
+
it("includes error stack in message when error is provided", () => {
|
|
2874
|
+
const originalError = new Error("Original error");
|
|
2875
|
+
const apiError = new APIError({
|
|
2876
|
+
error: originalError,
|
|
2877
|
+
title: "Wrapped error",
|
|
2878
|
+
});
|
|
2879
|
+
expect(apiError.message).toContain("Wrapped error");
|
|
2880
|
+
expect(originalError.stack).toBeDefined();
|
|
2881
|
+
expect(apiError.message).toContain(originalError.stack as string);
|
|
2882
|
+
});
|
|
1555
2883
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
2884
|
+
it("includes detail in message when provided", () => {
|
|
2885
|
+
const error = new APIError({
|
|
2886
|
+
detail: "More details here",
|
|
2887
|
+
title: "Test error",
|
|
2888
|
+
});
|
|
2889
|
+
expect(error.message).toContain("Test error");
|
|
2890
|
+
expect(error.message).toContain("More details here");
|
|
1559
2891
|
});
|
|
1560
2892
|
|
|
1561
|
-
it("
|
|
1562
|
-
|
|
1563
|
-
|
|
2893
|
+
it("sets fields in meta when provided", () => {
|
|
2894
|
+
const error = new APIError({
|
|
2895
|
+
fields: {email: "Invalid email format"},
|
|
2896
|
+
title: "Validation error",
|
|
2897
|
+
});
|
|
2898
|
+
expect(error.meta?.fields).toEqual({email: "Invalid email format"});
|
|
2899
|
+
});
|
|
2900
|
+
});
|
|
1564
2901
|
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2902
|
+
describe("errorsPlugin", () => {
|
|
2903
|
+
it("adds apiErrors field to schema", async () => {
|
|
2904
|
+
const mongoose = await import("mongoose");
|
|
2905
|
+
const {errorsPlugin} = await import("./errors");
|
|
1569
2906
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
expect(res.body.data.superTitle).toBe("Batman");
|
|
2907
|
+
const testSchema = new mongoose.Schema({name: String});
|
|
2908
|
+
errorsPlugin(testSchema);
|
|
1573
2909
|
|
|
1574
|
-
|
|
1575
|
-
expect(user?.superTitle).toBe("Batman");
|
|
2910
|
+
expect(testSchema.path("apiErrors")).toBeDefined();
|
|
1576
2911
|
});
|
|
2912
|
+
});
|
|
1577
2913
|
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
2914
|
+
describe("isAPIError", () => {
|
|
2915
|
+
it("returns true for APIError instances", () => {
|
|
2916
|
+
const {isAPIError} = require("./errors");
|
|
2917
|
+
const error = new APIError({title: "Test"});
|
|
2918
|
+
expect(isAPIError(error)).toBe(true);
|
|
2919
|
+
});
|
|
1583
2920
|
|
|
1584
|
-
|
|
1585
|
-
|
|
2921
|
+
it("returns false for regular Error instances", () => {
|
|
2922
|
+
const {isAPIError} = require("./errors");
|
|
2923
|
+
const error = new Error("Test");
|
|
2924
|
+
expect(isAPIError(error)).toBe(false);
|
|
2925
|
+
});
|
|
2926
|
+
});
|
|
1586
2927
|
|
|
1587
|
-
|
|
1588
|
-
|
|
2928
|
+
describe("getDisableExternalErrorTracking", () => {
|
|
2929
|
+
it("returns undefined for non-objects", () => {
|
|
2930
|
+
const {getDisableExternalErrorTracking} = require("./errors");
|
|
2931
|
+
expect(getDisableExternalErrorTracking(null)).toBeUndefined();
|
|
2932
|
+
expect(getDisableExternalErrorTracking("string")).toBeUndefined();
|
|
1589
2933
|
});
|
|
1590
2934
|
|
|
1591
|
-
it("
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
2935
|
+
it("returns value from APIError", () => {
|
|
2936
|
+
const {getDisableExternalErrorTracking} = require("./errors");
|
|
2937
|
+
const error = new APIError({disableExternalErrorTracking: true, title: "Test"});
|
|
2938
|
+
expect(getDisableExternalErrorTracking(error)).toBe(true);
|
|
2939
|
+
});
|
|
1596
2940
|
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2941
|
+
it("returns value from plain object with property", () => {
|
|
2942
|
+
const {getDisableExternalErrorTracking} = require("./errors");
|
|
2943
|
+
const obj = {disableExternalErrorTracking: true};
|
|
2944
|
+
expect(getDisableExternalErrorTracking(obj)).toBe(true);
|
|
1601
2945
|
});
|
|
2946
|
+
});
|
|
1602
2947
|
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
2948
|
+
describe("getAPIErrorBody", () => {
|
|
2949
|
+
it("includes all non-undefined fields", () => {
|
|
2950
|
+
const {getAPIErrorBody} = require("./errors");
|
|
2951
|
+
const error = new APIError({
|
|
2952
|
+
code: "TEST_CODE",
|
|
2953
|
+
detail: "Test detail",
|
|
2954
|
+
id: "error-123",
|
|
2955
|
+
links: {about: "http://example.com"},
|
|
2956
|
+
meta: {extra: "data"},
|
|
2957
|
+
source: {parameter: "id"},
|
|
2958
|
+
status: 400,
|
|
2959
|
+
title: "Test error",
|
|
2960
|
+
});
|
|
2961
|
+
const body = getAPIErrorBody(error);
|
|
1608
2962
|
|
|
1609
|
-
expect(
|
|
2963
|
+
expect(body.title).toBe("Test error");
|
|
2964
|
+
expect(body.status).toBe(400);
|
|
2965
|
+
expect(body.code).toBe("TEST_CODE");
|
|
2966
|
+
expect(body.detail).toBe("Test detail");
|
|
2967
|
+
expect(body.id).toBe("error-123");
|
|
2968
|
+
expect(body.links).toEqual({about: "http://example.com"});
|
|
2969
|
+
expect(body.source).toEqual({parameter: "id"});
|
|
2970
|
+
expect(body.meta).toEqual({extra: "data"});
|
|
2971
|
+
});
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
describe("apiUnauthorizedMiddleware", () => {
|
|
2975
|
+
it("returns 401 for Unauthorized errors", () => {
|
|
2976
|
+
const {apiUnauthorizedMiddleware} = require("./errors");
|
|
2977
|
+
const err = new Error("Unauthorized");
|
|
2978
|
+
const res = {
|
|
2979
|
+
json: function (data: any) {
|
|
2980
|
+
(this as any).body = data;
|
|
2981
|
+
return this;
|
|
2982
|
+
},
|
|
2983
|
+
send: function () {
|
|
2984
|
+
return this;
|
|
2985
|
+
},
|
|
2986
|
+
status: function (code: number) {
|
|
2987
|
+
(this as any).statusCode = code;
|
|
2988
|
+
return this;
|
|
2989
|
+
},
|
|
2990
|
+
};
|
|
2991
|
+
const next = () => {};
|
|
1610
2992
|
|
|
1611
|
-
|
|
1612
|
-
expect((
|
|
2993
|
+
apiUnauthorizedMiddleware(err, {}, res, next);
|
|
2994
|
+
expect((res as any).statusCode).toBe(401);
|
|
2995
|
+
expect((res as any).body.title).toBe("Unauthorized");
|
|
1613
2996
|
});
|
|
1614
2997
|
|
|
1615
|
-
it("
|
|
1616
|
-
const
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
superTitle: "Batman",
|
|
1623
|
-
})
|
|
1624
|
-
.expect(201);
|
|
2998
|
+
it("calls next for non-Unauthorized errors", () => {
|
|
2999
|
+
const {apiUnauthorizedMiddleware} = require("./errors");
|
|
3000
|
+
const err = new Error("Some other error");
|
|
3001
|
+
let nextCalled = false;
|
|
3002
|
+
const next = () => {
|
|
3003
|
+
nextCalled = true;
|
|
3004
|
+
};
|
|
1625
3005
|
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
3006
|
+
apiUnauthorizedMiddleware(err, {}, {}, next);
|
|
3007
|
+
expect(nextCalled).toBe(true);
|
|
3008
|
+
});
|
|
3009
|
+
});
|
|
3010
|
+
});
|
|
1631
3011
|
|
|
1632
|
-
|
|
1633
|
-
|
|
3012
|
+
describe("permissions module", () => {
|
|
3013
|
+
describe("OwnerQueryFilter", () => {
|
|
3014
|
+
it("returns ownerId filter when user is provided", () => {
|
|
3015
|
+
const {OwnerQueryFilter} = require("./permissions");
|
|
3016
|
+
const user = {id: "user-123"};
|
|
3017
|
+
const filter = OwnerQueryFilter(user);
|
|
3018
|
+
expect(filter).toEqual({ownerId: "user-123"});
|
|
1634
3019
|
});
|
|
1635
3020
|
|
|
1636
|
-
it("
|
|
1637
|
-
|
|
1638
|
-
|
|
3021
|
+
it("returns null when user is undefined", () => {
|
|
3022
|
+
const {OwnerQueryFilter} = require("./permissions");
|
|
3023
|
+
const filter = OwnerQueryFilter(undefined);
|
|
3024
|
+
expect(filter).toBeNull();
|
|
3025
|
+
});
|
|
3026
|
+
});
|
|
1639
3027
|
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
3028
|
+
describe("Permissions.IsAuthenticatedOrReadOnly", () => {
|
|
3029
|
+
it("returns true for authenticated non-anonymous users", () => {
|
|
3030
|
+
const {Permissions} = require("./permissions");
|
|
3031
|
+
const user = {id: "user-123", isAnonymous: false};
|
|
3032
|
+
expect(Permissions.IsAuthenticatedOrReadOnly("create", user)).toBe(true);
|
|
3033
|
+
});
|
|
3034
|
+
|
|
3035
|
+
it("returns true for read methods when user is anonymous", () => {
|
|
3036
|
+
const {Permissions} = require("./permissions");
|
|
3037
|
+
const user = {id: "user-123", isAnonymous: true};
|
|
3038
|
+
expect(Permissions.IsAuthenticatedOrReadOnly("list", user)).toBe(true);
|
|
3039
|
+
expect(Permissions.IsAuthenticatedOrReadOnly("read", user)).toBe(true);
|
|
3040
|
+
});
|
|
3041
|
+
|
|
3042
|
+
it("returns false for write methods when user is anonymous", () => {
|
|
3043
|
+
const {Permissions} = require("./permissions");
|
|
3044
|
+
const user = {id: "user-123", isAnonymous: true};
|
|
3045
|
+
expect(Permissions.IsAuthenticatedOrReadOnly("create", user)).toBe(false);
|
|
3046
|
+
expect(Permissions.IsAuthenticatedOrReadOnly("update", user)).toBe(false);
|
|
3047
|
+
expect(Permissions.IsAuthenticatedOrReadOnly("delete", user)).toBe(false);
|
|
3048
|
+
});
|
|
3049
|
+
});
|
|
3050
|
+
|
|
3051
|
+
describe("Permissions.IsOwnerOrReadOnly", () => {
|
|
3052
|
+
it("returns true when no object is provided", () => {
|
|
3053
|
+
const {Permissions} = require("./permissions");
|
|
3054
|
+
expect(Permissions.IsOwnerOrReadOnly("update", {id: "user-123"}, undefined)).toBe(true);
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
it("returns true for admin users", () => {
|
|
3058
|
+
const {Permissions} = require("./permissions");
|
|
3059
|
+
const user = {admin: true, id: "admin-123"};
|
|
3060
|
+
const obj = {ownerId: "other-user"};
|
|
3061
|
+
expect(Permissions.IsOwnerOrReadOnly("update", user, obj)).toBe(true);
|
|
3062
|
+
});
|
|
3063
|
+
|
|
3064
|
+
it("returns true when user is owner", () => {
|
|
3065
|
+
const {Permissions} = require("./permissions");
|
|
3066
|
+
const user = {id: "user-123"};
|
|
3067
|
+
const obj = {ownerId: "user-123"};
|
|
3068
|
+
expect(Permissions.IsOwnerOrReadOnly("update", user, obj)).toBe(true);
|
|
3069
|
+
});
|
|
3070
|
+
|
|
3071
|
+
it("returns true for read methods when not owner", () => {
|
|
3072
|
+
const {Permissions} = require("./permissions");
|
|
3073
|
+
const user = {id: "user-123"};
|
|
3074
|
+
const obj = {ownerId: "other-user"};
|
|
3075
|
+
expect(Permissions.IsOwnerOrReadOnly("list", user, obj)).toBe(true);
|
|
3076
|
+
expect(Permissions.IsOwnerOrReadOnly("read", user, obj)).toBe(true);
|
|
3077
|
+
});
|
|
3078
|
+
|
|
3079
|
+
it("returns false for write methods when not owner", () => {
|
|
3080
|
+
const {Permissions} = require("./permissions");
|
|
3081
|
+
const user = {id: "user-123"};
|
|
3082
|
+
const obj = {ownerId: "other-user"};
|
|
3083
|
+
expect(Permissions.IsOwnerOrReadOnly("update", user, obj)).toBe(false);
|
|
3084
|
+
expect(Permissions.IsOwnerOrReadOnly("delete", user, obj)).toBe(false);
|
|
3085
|
+
});
|
|
3086
|
+
});
|
|
3087
|
+
});
|
|
3088
|
+
|
|
3089
|
+
describe("utils module", () => {
|
|
3090
|
+
describe("isValidObjectId", () => {
|
|
3091
|
+
it("returns true for valid ObjectId strings", () => {
|
|
3092
|
+
const {isValidObjectId} = require("./utils");
|
|
3093
|
+
expect(isValidObjectId("507f1f77bcf86cd799439011")).toBe(true);
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
it("returns false for invalid ObjectId strings", () => {
|
|
3097
|
+
const {isValidObjectId} = require("./utils");
|
|
3098
|
+
expect(isValidObjectId("invalid-id")).toBe(false);
|
|
3099
|
+
expect(isValidObjectId("12345")).toBe(false);
|
|
3100
|
+
expect(isValidObjectId("")).toBe(false);
|
|
3101
|
+
});
|
|
1646
3102
|
|
|
1647
|
-
|
|
1648
|
-
|
|
3103
|
+
it("returns false for 12-character strings that are not valid ObjectIds", () => {
|
|
3104
|
+
const {isValidObjectId} = require("./utils");
|
|
3105
|
+
// mongoose's native isValid returns true for any 12-char string
|
|
3106
|
+
// but our implementation should return false since toString won't match
|
|
3107
|
+
expect(isValidObjectId("123456789012")).toBe(false);
|
|
1649
3108
|
});
|
|
3109
|
+
});
|
|
3110
|
+
|
|
3111
|
+
describe("timeout", () => {
|
|
3112
|
+
it("resolves after specified time", async () => {
|
|
3113
|
+
const {timeout} = require("./utils");
|
|
3114
|
+
const start = Date.now();
|
|
3115
|
+
await timeout(50);
|
|
3116
|
+
const elapsed = Date.now() - start;
|
|
3117
|
+
expect(elapsed).toBeGreaterThanOrEqual(40);
|
|
3118
|
+
});
|
|
3119
|
+
});
|
|
3120
|
+
|
|
3121
|
+
// Note: Comprehensive checkModelsStrict tests are in utils.test.ts with mocked mongoose
|
|
3122
|
+
});
|
|
3123
|
+
|
|
3124
|
+
describe("populate module", () => {
|
|
3125
|
+
describe("unpopulate", () => {
|
|
3126
|
+
it("throws error when path is empty", async () => {
|
|
3127
|
+
const {unpopulate} = await import("./populate");
|
|
3128
|
+
const doc = {name: "test"};
|
|
3129
|
+
expect(() => unpopulate(doc as any, "")).toThrow("path is required");
|
|
3130
|
+
});
|
|
3131
|
+
|
|
3132
|
+
it("unpopulates single populated field", async () => {
|
|
3133
|
+
const {unpopulate} = await import("./populate");
|
|
3134
|
+
const doc = {
|
|
3135
|
+
name: "test",
|
|
3136
|
+
ownerId: {_id: "owner-123", email: "owner@test.com"},
|
|
3137
|
+
};
|
|
3138
|
+
const result = unpopulate(doc as any, "ownerId") as any;
|
|
3139
|
+
expect(result.ownerId).toBe("owner-123");
|
|
3140
|
+
});
|
|
3141
|
+
|
|
3142
|
+
it("unpopulates array of populated fields", async () => {
|
|
3143
|
+
const {unpopulate} = await import("./populate");
|
|
3144
|
+
const doc = {
|
|
3145
|
+
items: [{_id: "item-1", name: "Item 1"}, {_id: "item-2", name: "Item 2"}, "item-3"],
|
|
3146
|
+
name: "test",
|
|
3147
|
+
};
|
|
3148
|
+
const result = unpopulate(doc as any, "items") as any;
|
|
3149
|
+
expect(result.items).toEqual(["item-1", "item-2", "item-3"]);
|
|
3150
|
+
});
|
|
3151
|
+
|
|
3152
|
+
it("handles nested paths", async () => {
|
|
3153
|
+
const {unpopulate} = await import("./populate");
|
|
3154
|
+
const doc = {
|
|
3155
|
+
name: "test",
|
|
3156
|
+
nested: {
|
|
3157
|
+
items: [
|
|
3158
|
+
{_id: "item-1", name: "Item 1"},
|
|
3159
|
+
{_id: "item-2", name: "Item 2"},
|
|
3160
|
+
],
|
|
3161
|
+
},
|
|
3162
|
+
};
|
|
3163
|
+
const result = unpopulate(doc as any, "nested.items") as any;
|
|
3164
|
+
expect(result.nested.items).toEqual(["item-1", "item-2"]);
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
it("returns original doc when path does not exist", async () => {
|
|
3168
|
+
const {unpopulate} = await import("./populate");
|
|
3169
|
+
const doc = {name: "test"};
|
|
3170
|
+
const result = unpopulate(doc as any, "nonexistent") as any;
|
|
3171
|
+
expect(result).toEqual(doc);
|
|
3172
|
+
});
|
|
3173
|
+
|
|
3174
|
+
it("handles nested array paths", async () => {
|
|
3175
|
+
const {unpopulate} = await import("./populate");
|
|
3176
|
+
const doc = {
|
|
3177
|
+
containers: [
|
|
3178
|
+
{items: [{_id: "item-1"}, {_id: "item-2"}]},
|
|
3179
|
+
{items: [{_id: "item-3"}, {_id: "item-4"}]},
|
|
3180
|
+
],
|
|
3181
|
+
name: "test",
|
|
3182
|
+
};
|
|
3183
|
+
const result = unpopulate(doc as any, "containers.items") as any;
|
|
3184
|
+
expect(result.containers[0].items).toEqual(["item-1", "item-2"]);
|
|
3185
|
+
expect(result.containers[1].items).toEqual(["item-3", "item-4"]);
|
|
3186
|
+
});
|
|
3187
|
+
});
|
|
3188
|
+
});
|
|
3189
|
+
|
|
3190
|
+
describe("auth module edge cases", () => {
|
|
3191
|
+
describe("generateTokens", () => {
|
|
3192
|
+
it("returns null tokens when user is missing", async () => {
|
|
3193
|
+
const {generateTokens} = await import("./auth");
|
|
3194
|
+
const result = await generateTokens(null);
|
|
3195
|
+
expect(result.token).toBeNull();
|
|
3196
|
+
expect(result.refreshToken).toBeNull();
|
|
3197
|
+
});
|
|
3198
|
+
|
|
3199
|
+
it("returns null tokens when user has no _id", async () => {
|
|
3200
|
+
const {generateTokens} = await import("./auth");
|
|
3201
|
+
const result = await generateTokens({email: "test@test.com"});
|
|
3202
|
+
expect(result.token).toBeNull();
|
|
3203
|
+
expect(result.refreshToken).toBeNull();
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
it("includes custom payload from generateJWTPayload option", async () => {
|
|
3207
|
+
const {generateTokens} = await import("./auth");
|
|
3208
|
+
const jwt = await import("jsonwebtoken");
|
|
3209
|
+
|
|
3210
|
+
const user = {_id: "user-123"};
|
|
3211
|
+
const result = await generateTokens(user, {
|
|
3212
|
+
generateJWTPayload: (u) => ({customField: "customValue", userId: u._id}),
|
|
3213
|
+
});
|
|
1650
3214
|
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
3215
|
+
expect(result.token).toBeDefined();
|
|
3216
|
+
const decoded = jwt.decode(result.token as string) as any;
|
|
3217
|
+
expect(decoded.customField).toBe("customValue");
|
|
3218
|
+
expect(decoded.id).toBe("user-123");
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
it("uses custom token expiration from generateTokenExpiration option", async () => {
|
|
3222
|
+
const {generateTokens} = await import("./auth");
|
|
3223
|
+
const jwt = await import("jsonwebtoken");
|
|
3224
|
+
|
|
3225
|
+
const user = {_id: "user-123"};
|
|
3226
|
+
const result = await generateTokens(user, {
|
|
3227
|
+
generateTokenExpiration: () => "1h",
|
|
3228
|
+
});
|
|
1654
3229
|
|
|
1655
|
-
|
|
3230
|
+
expect(result.token).toBeDefined();
|
|
3231
|
+
const decoded = jwt.decode(result.token as string) as any;
|
|
3232
|
+
// Check that exp is roughly 1 hour from now (within 5 seconds tolerance)
|
|
3233
|
+
const expectedExp = Math.floor(Date.now() / 1000) + 3600;
|
|
3234
|
+
expect(decoded.exp).toBeGreaterThan(expectedExp - 5);
|
|
3235
|
+
expect(decoded.exp).toBeLessThan(expectedExp + 5);
|
|
3236
|
+
});
|
|
3237
|
+
|
|
3238
|
+
it("uses custom refresh token expiration from generateRefreshTokenExpiration option", async () => {
|
|
3239
|
+
const {generateTokens} = await import("./auth");
|
|
3240
|
+
const jwt = await import("jsonwebtoken");
|
|
3241
|
+
|
|
3242
|
+
const user = {_id: "user-123"};
|
|
3243
|
+
const result = await generateTokens(user, {
|
|
3244
|
+
generateRefreshTokenExpiration: () => "7d",
|
|
3245
|
+
});
|
|
1656
3246
|
|
|
1657
|
-
|
|
1658
|
-
|
|
3247
|
+
expect(result.refreshToken).toBeDefined();
|
|
3248
|
+
const decoded = jwt.decode(result.refreshToken as string) as any;
|
|
3249
|
+
// Check that exp is roughly 7 days from now
|
|
3250
|
+
const expectedExp = Math.floor(Date.now() / 1000) + 7 * 24 * 3600;
|
|
3251
|
+
expect(decoded.exp).toBeGreaterThan(expectedExp - 10);
|
|
3252
|
+
expect(decoded.exp).toBeLessThan(expectedExp + 10);
|
|
1659
3253
|
});
|
|
1660
3254
|
});
|
|
1661
3255
|
});
|