@terreno/api 0.0.11-beta.1 → 0.0.11

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/src/api.test.ts CHANGED
@@ -1,17 +1,29 @@
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";
4
6
  import qs from "qs";
5
7
  import supertest from "supertest";
6
8
  import type TestAgent from "supertest/lib/agent";
7
9
 
8
- import {addPopulateToQuery, modelRouter} from "./api";
10
+ import {modelRouter} from "./api";
9
11
  import {addAuthRoutes, setupAuth} from "./auth";
10
12
  import {APIError} from "./errors";
11
13
  import {logRequests} from "./expressServer";
12
14
  import {Permissions} from "./permissions";
13
- import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
14
- import {AdminOwnerTransformer} from "./transformers";
15
+ import {
16
+ authAsUser,
17
+ type Food,
18
+ FoodModel,
19
+ getBaseServer,
20
+ type StaffUser,
21
+ StaffUserModel,
22
+ type SuperUser,
23
+ SuperUserModel,
24
+ setupDb,
25
+ UserModel,
26
+ } from "./tests";
15
27
 
16
28
  describe("@terreno/api", () => {
17
29
  let server: TestAgent;
@@ -1462,1794 +1474,188 @@ describe("@terreno/api", () => {
1462
1474
  });
1463
1475
  });
1464
1476
 
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;
2691
- let agent: TestAgent;
2692
-
2693
- beforeEach(async () => {
2694
- [_admin] = await setupDb();
2695
-
2696
- app = getBaseServer();
2697
- setupAuth(app, UserModel as any);
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
-
2727
- app.use(
2728
- "/softdelete",
2729
- modelRouter(SoftDeleteModel, {
2730
- allowAnonymous: true,
2731
- permissions: {
2732
- create: [Permissions.IsAny],
2733
- delete: [Permissions.IsAny],
2734
- list: [Permissions.IsAny],
2735
- read: [Permissions.IsAny],
2736
- update: [Permissions.IsAny],
2737
- },
2738
- })
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;
1477
+ describe("discriminator", () => {
1478
+ let superUser: mongoose.Document<SuperUser>;
1479
+ let staffUser: mongoose.Document<StaffUser>;
1480
+ let notAdmin: mongoose.Document;
2759
1481
  let agent: TestAgent;
2760
1482
 
2761
1483
  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
- });
1484
+ [notAdmin] = await setupDb();
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;
2776
1497
 
2777
1498
  app = getBaseServer();
2778
1499
  setupAuth(app, UserModel as any);
2779
1500
  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
- );
2797
- server = supertest(app);
2798
- agent = await authAsUser(app, "admin");
2799
-
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");
2803
- });
2804
-
2805
- it("array operation preUpdate returning null for array PATCH throws error", async () => {
2806
1501
  app.use(
2807
- "/food",
2808
- modelRouter(FoodModel, {
1502
+ "/users",
1503
+ modelRouter(UserModel, {
2809
1504
  allowAnonymous: true,
1505
+ discriminatorKey: "__t",
2810
1506
  permissions: {
2811
- create: [Permissions.IsAdmin],
2812
- delete: [Permissions.IsAdmin],
2813
- list: [Permissions.IsAdmin],
2814
- read: [Permissions.IsAdmin],
2815
- update: [Permissions.IsAdmin],
1507
+ create: [Permissions.IsAuthenticated],
1508
+ delete: [Permissions.IsAuthenticated],
1509
+ list: [Permissions.IsAuthenticated],
1510
+ read: [Permissions.IsAuthenticated],
1511
+ update: [Permissions.IsAuthenticated],
2816
1512
  },
2817
- preUpdate: () => null,
2818
1513
  })
2819
1514
  );
2820
- server = supertest(app);
2821
- agent = await authAsUser(app, "admin");
2822
1515
 
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
- });
2829
-
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
1516
  server = supertest(app);
2848
- agent = await authAsUser(app, "admin");
2849
-
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
- });
2855
-
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
- });
2862
-
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
- });
2867
-
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);
2871
- });
2872
-
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
- });
2883
-
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");
2891
- });
2892
-
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
- });
2901
-
2902
- describe("errorsPlugin", () => {
2903
- it("adds apiErrors field to schema", async () => {
2904
- const mongoose = await import("mongoose");
2905
- const {errorsPlugin} = await import("./errors");
2906
-
2907
- const testSchema = new mongoose.Schema({name: String});
2908
- errorsPlugin(testSchema);
2909
-
2910
- expect(testSchema.path("apiErrors")).toBeDefined();
2911
- });
2912
- });
2913
-
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
- });
2920
-
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
- });
2927
-
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();
2933
- });
2934
-
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
- });
2940
-
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);
2945
- });
2946
- });
2947
-
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);
2962
-
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 = () => {};
2992
1517
 
2993
- apiUnauthorizedMiddleware(err, {}, res, next);
2994
- expect((res as any).statusCode).toBe(401);
2995
- expect((res as any).body.title).toBe("Unauthorized");
1518
+ agent = await authAsUser(app, "notAdmin");
2996
1519
  });
2997
1520
 
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
- };
1521
+ it("gets all users", async () => {
1522
+ const res = await agent.get("/users").expect(200);
1523
+ expect(res.body.data).toHaveLength(5);
3005
1524
 
3006
- apiUnauthorizedMiddleware(err, {}, {}, next);
3007
- expect(nextCalled).toBe(true);
3008
- });
3009
- });
3010
- });
1525
+ const data = sortBy(res.body.data, ["email"]);
3011
1526
 
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"});
3019
- });
1527
+ expect(data[0].email).toBe("admin+other@example.com");
1528
+ expect(data[0].department).toBeUndefined();
1529
+ expect(data[0].supertitle).toBeUndefined();
1530
+ expect(data[0].__t).toBeUndefined();
3020
1531
 
3021
- it("returns null when user is undefined", () => {
3022
- const {OwnerQueryFilter} = require("./permissions");
3023
- const filter = OwnerQueryFilter(undefined);
3024
- expect(filter).toBeNull();
3025
- });
3026
- });
1532
+ expect(data[1].email).toBe("admin@example.com");
1533
+ expect(data[1].department).toBeUndefined();
1534
+ expect(data[1].supertitle).toBeUndefined();
1535
+ expect(data[1].__t).toBeUndefined();
3027
1536
 
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
- });
1537
+ expect(data[2].email).toBe("notAdmin@example.com");
1538
+ expect(data[2].department).toBeUndefined();
1539
+ expect(data[2].supertitle).toBeUndefined();
1540
+ expect(data[2].__t).toBeUndefined();
3034
1541
 
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
- });
1542
+ expect(data[3].email).toBe("staff@example.com");
1543
+ expect(data[3].department).toBe("Accounting");
1544
+ expect(data[3].supertitle).toBeUndefined();
1545
+ expect(data[3].__t).toBe("Staff");
3041
1546
 
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);
1547
+ expect(data[4].email).toBe("superuser@example.com");
1548
+ expect(data[4].department).toBeUndefined();
1549
+ expect(data[4].superTitle).toBe("Super Man");
1550
+ expect(data[4].__t).toBe("SuperUser");
3048
1551
  });
3049
- });
3050
1552
 
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
- });
1553
+ it("gets a discriminated user", async () => {
1554
+ const res = await agent.get(`/users/${superUser._id}`).expect(200);
3056
1555
 
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);
1556
+ expect(res.body.data.email).toBe("superuser@example.com");
1557
+ expect(res.body.data.department).toBeUndefined();
1558
+ expect(res.body.data.superTitle).toBe("Super Man");
3062
1559
  });
3063
1560
 
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
- });
1561
+ it("updates a discriminated user", async () => {
1562
+ // Fails without __t.
1563
+ await agent.patch(`/users/${superUser._id}`).send({superTitle: "Batman"}).expect(404);
3070
1564
 
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
- });
1565
+ const res = await agent
1566
+ .patch(`/users/${superUser._id}`)
1567
+ .send({__t: "SuperUser", superTitle: "Batman"})
1568
+ .expect(200);
3078
1569
 
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
- });
1570
+ expect(res.body.data.email).toBe("superuser@example.com");
1571
+ expect(res.body.data.department).toBeUndefined();
1572
+ expect(res.body.data.superTitle).toBe("Batman");
3088
1573
 
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);
1574
+ const user = await SuperUserModel.findById(superUser._id);
1575
+ expect(user?.superTitle).toBe("Batman");
3094
1576
  });
3095
1577
 
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
- });
1578
+ it("updates a base user", async () => {
1579
+ const res = await agent
1580
+ .patch(`/users/${notAdmin._id}`)
1581
+ .send({email: "newemail@example.com", superTitle: "The Boss"})
1582
+ .expect(200);
3102
1583
 
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);
3108
- });
3109
- });
1584
+ expect(res.body.data.email).toBe("newemail@example.com");
1585
+ expect(res.body.data.superTitle).toBeUndefined();
3110
1586
 
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);
1587
+ const user = await SuperUserModel.findById(notAdmin._id);
1588
+ expect(user?.superTitle).toBeUndefined();
3118
1589
  });
3119
- });
3120
-
3121
- // Note: Comprehensive checkModelsStrict tests are in utils.test.ts with mocked mongoose
3122
- });
3123
1590
 
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
- });
1591
+ it("cannot update discriminator key", async () => {
1592
+ await agent
1593
+ .patch(`/users/${notAdmin._id}`)
1594
+ .send({__t: "Staff", superTitle: "Batman"})
1595
+ .expect(404);
3166
1596
 
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);
1597
+ await agent
1598
+ .patch(`/users/${staffUser._id}`)
1599
+ .send({__t: "SuperUser", superTitle: "Batman"})
1600
+ .expect(404);
3172
1601
  });
3173
1602
 
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
- });
1603
+ it("updating a field on another discriminated model does nothing", async () => {
1604
+ const res = await agent
1605
+ .patch(`/users/${superUser._id}`)
1606
+ .send({__t: "SuperUser", department: "Journalism"})
1607
+ .expect(200);
3189
1608
 
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
- });
1609
+ expect(res.body.data.department).toBeUndefined();
3198
1610
 
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();
1611
+ const user = await SuperUserModel.findById(superUser._id);
1612
+ expect((user as any)?.department).toBeUndefined();
3204
1613
  });
3205
1614
 
3206
- it("includes custom payload from generateJWTPayload option", async () => {
3207
- const {generateTokens} = await import("./auth");
3208
- const jwt = await import("jsonwebtoken");
1615
+ it("creates a discriminated user", async () => {
1616
+ const res = await agent
1617
+ .post("/users")
1618
+ .send({
1619
+ __t: "SuperUser",
1620
+ department: "R&D",
1621
+ email: "brucewayne@example.com",
1622
+ superTitle: "Batman",
1623
+ })
1624
+ .expect(201);
3209
1625
 
3210
- const user = {_id: "user-123"};
3211
- const result = await generateTokens(user, {
3212
- generateJWTPayload: (u) => ({customField: "customValue", userId: u._id}),
3213
- });
1626
+ expect(res.body.data.email).toBe("brucewayne@example.com");
1627
+ // Because we pass __t, this should create a SuperUser which has no department, so this is
1628
+ // dropped.
1629
+ expect(res.body.data.department).toBeUndefined();
1630
+ expect(res.body.data.superTitle).toBe("Batman");
3214
1631
 
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");
1632
+ const user = await SuperUserModel.findById(res.body.data._id);
1633
+ expect(user?.superTitle).toBe("Batman");
3219
1634
  });
3220
1635
 
3221
- it("uses custom token expiration from generateTokenExpiration option", async () => {
3222
- const {generateTokens} = await import("./auth");
3223
- const jwt = await import("jsonwebtoken");
1636
+ it("deletes a discriminated user", async () => {
1637
+ // Fails without __t.
1638
+ await agent.delete(`/users/${superUser._id}`).expect(404);
3224
1639
 
3225
- const user = {_id: "user-123"};
3226
- const result = await generateTokens(user, {
3227
- generateTokenExpiration: () => "1h",
3228
- });
1640
+ await agent
1641
+ .delete(`/users/${superUser._id}`)
1642
+ .send({
1643
+ __t: "SuperUser",
1644
+ })
1645
+ .expect(204);
3229
1646
 
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);
1647
+ const user = await SuperUserModel.findById(superUser._id);
1648
+ expect(user).toBeNull();
3236
1649
  });
3237
1650
 
3238
- it("uses custom refresh token expiration from generateRefreshTokenExpiration option", async () => {
3239
- const {generateTokens} = await import("./auth");
3240
- const jwt = await import("jsonwebtoken");
1651
+ it("deletes a base user", async () => {
1652
+ // Fails for base user with __t
1653
+ await agent.delete(`/users/${notAdmin._id}`).send({__t: "SuperUser"}).expect(404);
3241
1654
 
3242
- const user = {_id: "user-123"};
3243
- const result = await generateTokens(user, {
3244
- generateRefreshTokenExpiration: () => "7d",
3245
- });
1655
+ await agent.delete(`/users/${notAdmin._id}`).expect(204);
3246
1656
 
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);
1657
+ const user = await SuperUserModel.findById(notAdmin._id);
1658
+ expect(user).toBeNull();
3253
1659
  });
3254
1660
  });
3255
1661
  });