@saluzi/saluzi-edu 0.2.9 → 0.2.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/dist/cli.js +336 -333
- package/dist/rcs-web/assets/Dashboard-BwotBWlJ.js +1 -0
- package/dist/rcs-web/assets/Join-4yrksbdX.js +1 -0
- package/dist/rcs-web/assets/Login-Cxm_yEeE.js +1 -0
- package/dist/rcs-web/assets/SessionDetail-DNdfPOYy.js +50 -0
- package/dist/rcs-web/assets/Setup-BiAAxugt.js +1 -0
- package/dist/rcs-web/assets/ShareLanding-CP3Z6ckr.js +1 -0
- package/dist/rcs-web/assets/TeamCreate-DgdCwRSb.js +1 -0
- package/dist/rcs-web/assets/TeamDetail-CkNTFoMB.js +11 -0
- package/dist/rcs-web/assets/TeamList-Bf0sQ5RQ.js +1 -0
- package/dist/rcs-web/assets/TeamSettings-BaNAfXU3.js +1 -0
- package/dist/rcs-web/assets/UserSettings-CsutQjxG.js +1 -0
- package/{packages/remote-control-server/web/dist/assets/arc-D90597o-.js → dist/rcs-web/assets/arc-BxxmNQO6.js} +1 -1
- package/dist/rcs-web/assets/architectureDiagram-3BPJPVTR-DBjkRdbd.js +36 -0
- package/dist/rcs-web/assets/{blockDiagram-GPEHLZMM-DHd8IbOs.js → blockDiagram-GPEHLZMM-BF6COWlM.js} +2 -2
- package/dist/rcs-web/assets/{c4Diagram-AAUBKEIU-CjFkQJ9h.js → c4Diagram-AAUBKEIU-DHOf390v.js} +2 -2
- package/dist/rcs-web/assets/channel-JsvbMQjW.js +1 -0
- package/dist/rcs-web/assets/{chunk-2J33WTMH-C21XWnGZ.js → chunk-2J33WTMH-CeJN8yjM.js} +1 -1
- package/dist/rcs-web/assets/{chunk-4BX2VUAB-B3SXiJlt.js → chunk-4BX2VUAB-jP76_P2A.js} +1 -1
- package/dist/rcs-web/assets/{chunk-55IACEB6-DjImv45F.js → chunk-55IACEB6-BsFA6emB.js} +1 -1
- package/{packages/remote-control-server/web/dist/assets/chunk-727SXJPM-DtnaqsyD.js → dist/rcs-web/assets/chunk-727SXJPM-BF4HrQjZ.js} +1 -1
- package/dist/rcs-web/assets/{chunk-AQP2D5EJ-BVFgH5mS.js → chunk-AQP2D5EJ-DToJ74Kc.js} +1 -1
- package/dist/rcs-web/assets/{chunk-FMBD7UC4-Bl8jgb57.js → chunk-FMBD7UC4-DVTARgsa.js} +1 -1
- package/dist/rcs-web/assets/{chunk-ND2GUHAM-BpK111zh.js → chunk-ND2GUHAM-C50Gqv9c.js} +1 -1
- package/dist/rcs-web/assets/{chunk-QZHKN3VN-ruhIHBim.js → chunk-QZHKN3VN-EsIIh6zV.js} +1 -1
- package/dist/rcs-web/assets/classDiagram-4FO5ZUOK-BXn8Uc4c.js +1 -0
- package/dist/rcs-web/assets/classDiagram-v2-Q7XG4LA2-BXn8Uc4c.js +1 -0
- package/dist/rcs-web/assets/code-block-IT6T5CEO-BnufHNTT.js +2 -0
- package/dist/rcs-web/assets/cose-bilkent-S5V4N54A-M6tNt14I.js +1 -0
- package/dist/rcs-web/assets/dagre-BM42HDAG-DaTm29qG.js +4 -0
- package/dist/rcs-web/assets/diagram-2AECGRRQ-fqoeOvq2.js +43 -0
- package/dist/rcs-web/assets/diagram-5GNKFQAL-6s2CiaGG.js +10 -0
- package/dist/rcs-web/assets/{diagram-KO2AKTUF-Uy3PDP5S.js → diagram-KO2AKTUF-C0aw_N95.js} +2 -2
- package/dist/rcs-web/assets/diagram-LMA3HP47-C8_Rv3rB.js +24 -0
- package/dist/rcs-web/assets/diagram-OG6HWLK6-Dae4u8Uc.js +24 -0
- package/{packages/remote-control-server/web/dist/assets/erDiagram-TEJ5UH35-sre_MTeK.js → dist/rcs-web/assets/erDiagram-TEJ5UH35-CosS5VWr.js} +13 -13
- package/dist/rcs-web/assets/{flowDiagram-I6XJVG4X-DwrWaLoe.js → flowDiagram-I6XJVG4X-D_Mxs46-.js} +2 -2
- package/dist/rcs-web/assets/ganttDiagram-6RSMTGT7-B0jy8Am_.js +292 -0
- package/dist/rcs-web/assets/gitGraphDiagram-PVQCEYII-CSdu2HVI.js +106 -0
- package/dist/rcs-web/assets/i18n-Dbt-vEQ3.js +1 -0
- package/dist/rcs-web/assets/infoDiagram-5YYISTIA-Dcbc9L1S.js +2 -0
- package/{packages/remote-control-server/web/dist/assets/ishikawaDiagram-YF4QCWOH-SO0nn3--.js → dist/rcs-web/assets/ishikawaDiagram-YF4QCWOH-BLMjFE4t.js} +2 -2
- package/dist/rcs-web/assets/{journeyDiagram-JHISSGLW-D4igFUd4.js → journeyDiagram-JHISSGLW-Dw4nh1xI.js} +2 -2
- package/dist/rcs-web/assets/{kanban-definition-UN3LZRKU-Cm_yKsZC.js → kanban-definition-UN3LZRKU-DkU3j_ly.js} +2 -2
- package/dist/rcs-web/assets/{linear-4IMGll_E.js → linear-N5Cify7M.js} +1 -1
- package/dist/rcs-web/assets/main-30YqUvTd.js +238 -0
- package/dist/rcs-web/assets/main-EP6Lac2m.css +1 -0
- package/{packages/remote-control-server/web/dist/assets/mermaid-VLURNSYL-D_yQ7nAd.js → dist/rcs-web/assets/mermaid-VLURNSYL-gfiJUpl_.js} +5 -5
- package/dist/rcs-web/assets/{mermaid.core-B1qOUdiU.js → mermaid.core-B_Xc65Bf.js} +5 -5
- package/dist/rcs-web/assets/{mindmap-definition-RKZ34NQL-yETom5qt.js → mindmap-definition-RKZ34NQL-DUavHfNu.js} +2 -2
- package/{packages/remote-control-server/web/dist/assets/pieDiagram-4H26LBE5-KIhqXGT6.js → dist/rcs-web/assets/pieDiagram-4H26LBE5-DSJaToTU.js} +2 -2
- package/{packages/remote-control-server/web/dist/assets/quadrantDiagram-W4KKPZXB-DISqmoA6.js → dist/rcs-web/assets/quadrantDiagram-W4KKPZXB-BFp6TnHj.js} +2 -2
- package/dist/rcs-web/assets/{requirementDiagram-4Y6WPE33-DsgT4ptk.js → requirementDiagram-4Y6WPE33-eil6F3-O.js} +2 -2
- package/{packages/remote-control-server/web/dist/assets/sankeyDiagram-5OEKKPKP-Dn67kbn0.js → dist/rcs-web/assets/sankeyDiagram-5OEKKPKP-DbGhoepK.js} +2 -2
- package/{packages/remote-control-server/web/dist/assets/sequenceDiagram-3UESZ5HK-CtWaXZHl.js → dist/rcs-web/assets/sequenceDiagram-3UESZ5HK-B0YrN1ud.js} +14 -14
- package/dist/rcs-web/assets/stateDiagram-AJRCARHV-CofFPj3N.js +1 -0
- package/dist/rcs-web/assets/stateDiagram-v2-BHNVJYJU-DPlcvPn1.js +1 -0
- package/{packages/remote-control-server/web/dist/assets/timeline-definition-PNZ67QCA-Dk5WUenC.js → dist/rcs-web/assets/timeline-definition-PNZ67QCA-Bx4dusy8.js} +2 -2
- package/dist/rcs-web/assets/useTeams-MDI4djXg.js +1 -0
- package/{packages/remote-control-server/web/dist/assets/vennDiagram-CIIHVFJN-TY8EiRrC.js → dist/rcs-web/assets/vennDiagram-CIIHVFJN-B_-3l-MF.js} +2 -2
- package/dist/rcs-web/assets/{wardleyDiagram-YWT4CUSO-BK3oi47X.js → wardleyDiagram-YWT4CUSO-DNSn-pGp.js} +2 -2
- package/dist/rcs-web/assets/workerType-OM09y7Lk.js +1 -0
- package/{packages/remote-control-server/web/dist/assets/xychartDiagram-2RQKCTM6-DhsxqX67.js → dist/rcs-web/assets/xychartDiagram-2RQKCTM6-TqUbNp0j.js} +2 -2
- package/dist/rcs-web/index.html +2 -3
- package/package.json +2 -1
- package/packages/remote-control-server/src/__tests__/acp-ws-handler.test.ts +7 -19
- package/packages/remote-control-server/src/__tests__/auth-login.test.ts +561 -0
- package/packages/remote-control-server/src/__tests__/auth-security.test.ts +547 -0
- package/packages/remote-control-server/src/__tests__/auth.test.ts +7 -18
- package/packages/remote-control-server/src/__tests__/bind-replacement.test.ts +251 -0
- package/packages/remote-control-server/src/__tests__/cookie.test.ts +155 -0
- package/packages/remote-control-server/src/__tests__/data-layer-fixes.test.ts +147 -0
- package/packages/remote-control-server/src/__tests__/db.test.ts +582 -0
- package/packages/remote-control-server/src/__tests__/design-gaps.test.ts +440 -0
- package/packages/remote-control-server/src/__tests__/disconnect-monitor.test.ts +2 -18
- package/packages/remote-control-server/src/__tests__/helpers/db.ts +43 -0
- package/packages/remote-control-server/src/__tests__/helpers/fixtures.ts +94 -0
- package/packages/remote-control-server/src/__tests__/helpers/mock-config.ts +31 -0
- package/packages/remote-control-server/src/__tests__/middleware.test.ts +4 -112
- package/packages/remote-control-server/src/__tests__/password.test.ts +76 -0
- package/packages/remote-control-server/src/__tests__/phase4-cleanup.test.ts +290 -0
- package/packages/remote-control-server/src/__tests__/phase4-integration.test.ts +224 -0
- package/packages/remote-control-server/src/__tests__/routes.test.ts +292 -207
- package/packages/remote-control-server/src/__tests__/services.test.ts +2 -18
- package/packages/remote-control-server/src/__tests__/session-visibility.test.ts +452 -0
- package/packages/remote-control-server/src/__tests__/session.test.ts +216 -0
- package/packages/remote-control-server/src/__tests__/shares.test.ts +1419 -0
- package/packages/remote-control-server/src/__tests__/sse-writer.test.ts +2 -18
- package/packages/remote-control-server/src/__tests__/teams.test.ts +2089 -0
- package/packages/remote-control-server/src/__tests__/work-dispatch.test.ts +2 -18
- package/packages/remote-control-server/src/__tests__/worker-token-removal.test.ts +487 -0
- package/packages/remote-control-server/src/__tests__/ws-handler.test.ts +2 -18
- package/packages/remote-control-server/src/auth/constants.ts +13 -0
- package/packages/remote-control-server/src/auth/cookie.ts +75 -0
- package/packages/remote-control-server/src/auth/middleware.ts +75 -165
- package/packages/remote-control-server/src/auth/password.ts +116 -0
- package/packages/remote-control-server/src/auth/session.ts +130 -0
- package/packages/remote-control-server/src/db/schema.sql +116 -0
- package/packages/remote-control-server/src/db/sqlite.ts +99 -0
- package/packages/remote-control-server/src/routes/acp/index.ts +6 -11
- package/packages/remote-control-server/src/routes/v1/environments.ts +111 -8
- package/packages/remote-control-server/src/routes/web/auth-routes.ts +634 -0
- package/packages/remote-control-server/src/routes/web/control.ts +5 -5
- package/packages/remote-control-server/src/routes/web/environments.ts +5 -27
- package/packages/remote-control-server/src/routes/web/sessions.ts +19 -41
- package/packages/remote-control-server/src/routes/web/shares.ts +371 -0
- package/packages/remote-control-server/src/routes/web/teams.ts +850 -0
- package/packages/remote-control-server/src/services/environment.ts +1 -4
- package/packages/remote-control-server/src/services/session-visibility.ts +79 -0
- package/packages/remote-control-server/src/services/session.ts +0 -13
- package/packages/remote-control-server/src/store.ts +150 -56
- package/packages/remote-control-server/src/types/api.ts +2 -8
- package/packages/remote-control-server/web/dist/assets/Dashboard-BwotBWlJ.js +1 -0
- package/packages/remote-control-server/web/dist/assets/Join-4yrksbdX.js +1 -0
- package/packages/remote-control-server/web/dist/assets/Login-Cxm_yEeE.js +1 -0
- package/packages/remote-control-server/web/dist/assets/SessionDetail-DNdfPOYy.js +50 -0
- package/packages/remote-control-server/web/dist/assets/Setup-BiAAxugt.js +1 -0
- package/packages/remote-control-server/web/dist/assets/ShareLanding-CP3Z6ckr.js +1 -0
- package/packages/remote-control-server/web/dist/assets/TeamCreate-DgdCwRSb.js +1 -0
- package/packages/remote-control-server/web/dist/assets/TeamDetail-CkNTFoMB.js +11 -0
- package/packages/remote-control-server/web/dist/assets/TeamList-Bf0sQ5RQ.js +1 -0
- package/packages/remote-control-server/web/dist/assets/TeamSettings-BaNAfXU3.js +1 -0
- package/packages/remote-control-server/web/dist/assets/UserSettings-CsutQjxG.js +1 -0
- package/{dist/rcs-web/assets/arc-D90597o-.js → packages/remote-control-server/web/dist/assets/arc-BxxmNQO6.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/architectureDiagram-3BPJPVTR-DBjkRdbd.js +36 -0
- package/packages/remote-control-server/web/dist/assets/{blockDiagram-GPEHLZMM-DHd8IbOs.js → blockDiagram-GPEHLZMM-BF6COWlM.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/{c4Diagram-AAUBKEIU-CjFkQJ9h.js → c4Diagram-AAUBKEIU-DHOf390v.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/channel-JsvbMQjW.js +1 -0
- package/packages/remote-control-server/web/dist/assets/{chunk-2J33WTMH-C21XWnGZ.js → chunk-2J33WTMH-CeJN8yjM.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/{chunk-4BX2VUAB-B3SXiJlt.js → chunk-4BX2VUAB-jP76_P2A.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/{chunk-55IACEB6-DjImv45F.js → chunk-55IACEB6-BsFA6emB.js} +1 -1
- package/{dist/rcs-web/assets/chunk-727SXJPM-DtnaqsyD.js → packages/remote-control-server/web/dist/assets/chunk-727SXJPM-BF4HrQjZ.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/{chunk-AQP2D5EJ-BVFgH5mS.js → chunk-AQP2D5EJ-DToJ74Kc.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/{chunk-FMBD7UC4-Bl8jgb57.js → chunk-FMBD7UC4-DVTARgsa.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/{chunk-ND2GUHAM-BpK111zh.js → chunk-ND2GUHAM-C50Gqv9c.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/{chunk-QZHKN3VN-ruhIHBim.js → chunk-QZHKN3VN-EsIIh6zV.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/classDiagram-4FO5ZUOK-BXn8Uc4c.js +1 -0
- package/packages/remote-control-server/web/dist/assets/classDiagram-v2-Q7XG4LA2-BXn8Uc4c.js +1 -0
- package/packages/remote-control-server/web/dist/assets/code-block-IT6T5CEO-BnufHNTT.js +2 -0
- package/packages/remote-control-server/web/dist/assets/cose-bilkent-S5V4N54A-M6tNt14I.js +1 -0
- package/packages/remote-control-server/web/dist/assets/dagre-BM42HDAG-DaTm29qG.js +4 -0
- package/packages/remote-control-server/web/dist/assets/diagram-2AECGRRQ-fqoeOvq2.js +43 -0
- package/packages/remote-control-server/web/dist/assets/diagram-5GNKFQAL-6s2CiaGG.js +10 -0
- package/packages/remote-control-server/web/dist/assets/{diagram-KO2AKTUF-Uy3PDP5S.js → diagram-KO2AKTUF-C0aw_N95.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/diagram-LMA3HP47-C8_Rv3rB.js +24 -0
- package/packages/remote-control-server/web/dist/assets/diagram-OG6HWLK6-Dae4u8Uc.js +24 -0
- package/{dist/rcs-web/assets/erDiagram-TEJ5UH35-sre_MTeK.js → packages/remote-control-server/web/dist/assets/erDiagram-TEJ5UH35-CosS5VWr.js} +13 -13
- package/packages/remote-control-server/web/dist/assets/{flowDiagram-I6XJVG4X-DwrWaLoe.js → flowDiagram-I6XJVG4X-D_Mxs46-.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/ganttDiagram-6RSMTGT7-B0jy8Am_.js +292 -0
- package/packages/remote-control-server/web/dist/assets/gitGraphDiagram-PVQCEYII-CSdu2HVI.js +106 -0
- package/packages/remote-control-server/web/dist/assets/i18n-Dbt-vEQ3.js +1 -0
- package/packages/remote-control-server/web/dist/assets/infoDiagram-5YYISTIA-Dcbc9L1S.js +2 -0
- package/{dist/rcs-web/assets/ishikawaDiagram-YF4QCWOH-SO0nn3--.js → packages/remote-control-server/web/dist/assets/ishikawaDiagram-YF4QCWOH-BLMjFE4t.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/{journeyDiagram-JHISSGLW-D4igFUd4.js → journeyDiagram-JHISSGLW-Dw4nh1xI.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/{kanban-definition-UN3LZRKU-Cm_yKsZC.js → kanban-definition-UN3LZRKU-DkU3j_ly.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/{linear-4IMGll_E.js → linear-N5Cify7M.js} +1 -1
- package/packages/remote-control-server/web/dist/assets/main-30YqUvTd.js +238 -0
- package/packages/remote-control-server/web/dist/assets/main-EP6Lac2m.css +1 -0
- package/{dist/rcs-web/assets/mermaid-VLURNSYL-D_yQ7nAd.js → packages/remote-control-server/web/dist/assets/mermaid-VLURNSYL-gfiJUpl_.js} +5 -5
- package/packages/remote-control-server/web/dist/assets/{mermaid.core-B1qOUdiU.js → mermaid.core-B_Xc65Bf.js} +5 -5
- package/packages/remote-control-server/web/dist/assets/{mindmap-definition-RKZ34NQL-yETom5qt.js → mindmap-definition-RKZ34NQL-DUavHfNu.js} +2 -2
- package/{dist/rcs-web/assets/pieDiagram-4H26LBE5-KIhqXGT6.js → packages/remote-control-server/web/dist/assets/pieDiagram-4H26LBE5-DSJaToTU.js} +2 -2
- package/{dist/rcs-web/assets/quadrantDiagram-W4KKPZXB-DISqmoA6.js → packages/remote-control-server/web/dist/assets/quadrantDiagram-W4KKPZXB-BFp6TnHj.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/{requirementDiagram-4Y6WPE33-DsgT4ptk.js → requirementDiagram-4Y6WPE33-eil6F3-O.js} +2 -2
- package/{dist/rcs-web/assets/sankeyDiagram-5OEKKPKP-Dn67kbn0.js → packages/remote-control-server/web/dist/assets/sankeyDiagram-5OEKKPKP-DbGhoepK.js} +2 -2
- package/{dist/rcs-web/assets/sequenceDiagram-3UESZ5HK-CtWaXZHl.js → packages/remote-control-server/web/dist/assets/sequenceDiagram-3UESZ5HK-B0YrN1ud.js} +14 -14
- package/packages/remote-control-server/web/dist/assets/stateDiagram-AJRCARHV-CofFPj3N.js +1 -0
- package/packages/remote-control-server/web/dist/assets/stateDiagram-v2-BHNVJYJU-DPlcvPn1.js +1 -0
- package/{dist/rcs-web/assets/timeline-definition-PNZ67QCA-Dk5WUenC.js → packages/remote-control-server/web/dist/assets/timeline-definition-PNZ67QCA-Bx4dusy8.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/useTeams-MDI4djXg.js +1 -0
- package/{dist/rcs-web/assets/vennDiagram-CIIHVFJN-TY8EiRrC.js → packages/remote-control-server/web/dist/assets/vennDiagram-CIIHVFJN-B_-3l-MF.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/{wardleyDiagram-YWT4CUSO-BK3oi47X.js → wardleyDiagram-YWT4CUSO-DNSn-pGp.js} +2 -2
- package/packages/remote-control-server/web/dist/assets/workerType-OM09y7Lk.js +1 -0
- package/{dist/rcs-web/assets/xychartDiagram-2RQKCTM6-DhsxqX67.js → packages/remote-control-server/web/dist/assets/xychartDiagram-2RQKCTM6-TqUbNp0j.js} +2 -2
- package/packages/remote-control-server/web/dist/index.html +2 -3
- package/dist/rcs-web/assets/Dashboard-Cyj-Bb86.js +0 -2
- package/dist/rcs-web/assets/SessionDetail-SA9QBkyj.js +0 -51
- package/dist/rcs-web/assets/architectureDiagram-3BPJPVTR-CG9q9_bk.js +0 -36
- package/dist/rcs-web/assets/channel-CQFGglg-.js +0 -1
- package/dist/rcs-web/assets/classDiagram-4FO5ZUOK-C5rBdShD.js +0 -1
- package/dist/rcs-web/assets/classDiagram-v2-Q7XG4LA2-C5rBdShD.js +0 -1
- package/dist/rcs-web/assets/code-block-IT6T5CEO-BLGFrBw3.js +0 -2
- package/dist/rcs-web/assets/cose-bilkent-S5V4N54A-DBKEXjqM.js +0 -1
- package/dist/rcs-web/assets/dagre-BM42HDAG-DB_SWlAe.js +0 -4
- package/dist/rcs-web/assets/diagram-2AECGRRQ-Gbm8G3Xz.js +0 -43
- package/dist/rcs-web/assets/diagram-5GNKFQAL-BBuWqfDc.js +0 -10
- package/dist/rcs-web/assets/diagram-LMA3HP47-DyUwNrlg.js +0 -24
- package/dist/rcs-web/assets/diagram-OG6HWLK6-BXpvRwkJ.js +0 -24
- package/dist/rcs-web/assets/ganttDiagram-6RSMTGT7-ByDYviDl.js +0 -292
- package/dist/rcs-web/assets/gitGraphDiagram-PVQCEYII-BUnOPw74.js +0 -106
- package/dist/rcs-web/assets/infoDiagram-5YYISTIA-B1RpwJHG.js +0 -2
- package/dist/rcs-web/assets/main-0A4a3FDU.css +0 -1
- package/dist/rcs-web/assets/main-Dom16YJk.js +0 -283
- package/dist/rcs-web/assets/qr-C0jP9HcD.js +0 -8
- package/dist/rcs-web/assets/qr-scanner-worker.min-D85Z9gVD.js +0 -98
- package/dist/rcs-web/assets/stateDiagram-AJRCARHV-D6xp64f3.js +0 -1
- package/dist/rcs-web/assets/stateDiagram-v2-BHNVJYJU-D76vZCyQ.js +0 -1
- package/packages/remote-control-server/web/dist/assets/Dashboard-Cyj-Bb86.js +0 -2
- package/packages/remote-control-server/web/dist/assets/SessionDetail-SA9QBkyj.js +0 -51
- package/packages/remote-control-server/web/dist/assets/architectureDiagram-3BPJPVTR-CG9q9_bk.js +0 -36
- package/packages/remote-control-server/web/dist/assets/channel-CQFGglg-.js +0 -1
- package/packages/remote-control-server/web/dist/assets/classDiagram-4FO5ZUOK-C5rBdShD.js +0 -1
- package/packages/remote-control-server/web/dist/assets/classDiagram-v2-Q7XG4LA2-C5rBdShD.js +0 -1
- package/packages/remote-control-server/web/dist/assets/code-block-IT6T5CEO-BLGFrBw3.js +0 -2
- package/packages/remote-control-server/web/dist/assets/cose-bilkent-S5V4N54A-DBKEXjqM.js +0 -1
- package/packages/remote-control-server/web/dist/assets/dagre-BM42HDAG-DB_SWlAe.js +0 -4
- package/packages/remote-control-server/web/dist/assets/diagram-2AECGRRQ-Gbm8G3Xz.js +0 -43
- package/packages/remote-control-server/web/dist/assets/diagram-5GNKFQAL-BBuWqfDc.js +0 -10
- package/packages/remote-control-server/web/dist/assets/diagram-LMA3HP47-DyUwNrlg.js +0 -24
- package/packages/remote-control-server/web/dist/assets/diagram-OG6HWLK6-BXpvRwkJ.js +0 -24
- package/packages/remote-control-server/web/dist/assets/ganttDiagram-6RSMTGT7-ByDYviDl.js +0 -292
- package/packages/remote-control-server/web/dist/assets/gitGraphDiagram-PVQCEYII-BUnOPw74.js +0 -106
- package/packages/remote-control-server/web/dist/assets/infoDiagram-5YYISTIA-B1RpwJHG.js +0 -2
- package/packages/remote-control-server/web/dist/assets/main-0A4a3FDU.css +0 -1
- package/packages/remote-control-server/web/dist/assets/main-Dom16YJk.js +0 -283
- package/packages/remote-control-server/web/dist/assets/qr-C0jP9HcD.js +0 -8
- package/packages/remote-control-server/web/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +0 -98
- package/packages/remote-control-server/web/dist/assets/stateDiagram-AJRCARHV-D6xp64f3.js +0 -1
- package/packages/remote-control-server/web/dist/assets/stateDiagram-v2-BHNVJYJU-D76vZCyQ.js +0 -1
|
@@ -0,0 +1,2089 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import { mockConfigModule } from './helpers/mock-config'
|
|
6
|
+
|
|
7
|
+
// Mock config before any module imports
|
|
8
|
+
mock.module('../config', () => mockConfigModule())
|
|
9
|
+
|
|
10
|
+
import { Hono } from 'hono'
|
|
11
|
+
import { Database } from 'bun:sqlite'
|
|
12
|
+
|
|
13
|
+
// Dynamic imports — Phase 2 modules may not exist yet (TDD)
|
|
14
|
+
let migrateDatabase: ((db: Database) => void) | undefined
|
|
15
|
+
let initDatabase: ((path?: string) => Database) | undefined
|
|
16
|
+
let resetDbSingleton: (() => void) | undefined
|
|
17
|
+
let getDb: (() => Database) | undefined
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const dbMod = await import('../db/sqlite')
|
|
21
|
+
migrateDatabase = dbMod.migrateDatabase
|
|
22
|
+
getDb = dbMod.getDb
|
|
23
|
+
initDatabase = dbMod.initDatabase
|
|
24
|
+
resetDbSingleton = dbMod.resetDbSingleton
|
|
25
|
+
} catch {
|
|
26
|
+
// Module not implemented yet
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let authRoutes: Hono | undefined
|
|
30
|
+
try {
|
|
31
|
+
const mod = await import('../routes/web/auth-routes')
|
|
32
|
+
authRoutes = mod.default
|
|
33
|
+
} catch {
|
|
34
|
+
// Module not implemented yet
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let teamRoutes: Hono | undefined
|
|
38
|
+
try {
|
|
39
|
+
const mod = await import('../routes/web/teams')
|
|
40
|
+
teamRoutes = mod.default
|
|
41
|
+
} catch {
|
|
42
|
+
// Module not implemented yet
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let environmentRoutes: Hono | undefined
|
|
46
|
+
try {
|
|
47
|
+
const mod = await import('../routes/web/environments')
|
|
48
|
+
environmentRoutes = mod.default
|
|
49
|
+
} catch {
|
|
50
|
+
// Module not implemented yet — fallback to Phase 1 module
|
|
51
|
+
try {
|
|
52
|
+
const mod = await import('../routes/web/environments')
|
|
53
|
+
environmentRoutes = mod.default
|
|
54
|
+
} catch {
|
|
55
|
+
// Still not available
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let v1EnvironmentRoutes: Hono | undefined
|
|
60
|
+
try {
|
|
61
|
+
const mod = await import('../routes/v1/environments')
|
|
62
|
+
v1EnvironmentRoutes = mod.default
|
|
63
|
+
} catch {
|
|
64
|
+
// Module not available
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Test app factory
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
function createApp(): Hono {
|
|
72
|
+
const app = new Hono()
|
|
73
|
+
if (authRoutes) app.route('/web/auth', authRoutes)
|
|
74
|
+
if (teamRoutes) app.route('/web', teamRoutes)
|
|
75
|
+
if (environmentRoutes) app.route('/web', environmentRoutes)
|
|
76
|
+
if (v1EnvironmentRoutes) app.route('/v1/environments', v1EnvironmentRoutes)
|
|
77
|
+
return app
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Database helpers
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function freshDb(): Database {
|
|
85
|
+
// Close and clean up any previous singleton
|
|
86
|
+
if (resetDbSingleton) {
|
|
87
|
+
try {
|
|
88
|
+
resetDbSingleton()
|
|
89
|
+
} catch {
|
|
90
|
+
// ignore cleanup errors
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Use a unique temp file per test to avoid WAL lock contention.
|
|
94
|
+
// The route handlers internally call getDb() (the singleton),
|
|
95
|
+
// so we must use initDatabase() to set up the singleton.
|
|
96
|
+
if (initDatabase) {
|
|
97
|
+
const uniquePath = resolve(
|
|
98
|
+
tmpdir(),
|
|
99
|
+
`rcs-test-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`,
|
|
100
|
+
)
|
|
101
|
+
return initDatabase(uniquePath)
|
|
102
|
+
}
|
|
103
|
+
// Fallback for when db module isn't available
|
|
104
|
+
const db = new Database(':memory:')
|
|
105
|
+
db.exec('PRAGMA foreign_keys = ON')
|
|
106
|
+
if (migrateDatabase) migrateDatabase(db)
|
|
107
|
+
return db
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function clearAllTables(db: Database): void {
|
|
111
|
+
db.exec('PRAGMA foreign_keys = OFF')
|
|
112
|
+
const tables = [
|
|
113
|
+
'session_shares',
|
|
114
|
+
'session_owners',
|
|
115
|
+
'team_environments',
|
|
116
|
+
'team_members',
|
|
117
|
+
'teams',
|
|
118
|
+
'invitations',
|
|
119
|
+
'session_tokens',
|
|
120
|
+
'users',
|
|
121
|
+
]
|
|
122
|
+
for (const t of tables) {
|
|
123
|
+
try {
|
|
124
|
+
db.exec(`DELETE FROM ${t}`)
|
|
125
|
+
} catch {
|
|
126
|
+
// table may not exist
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
db.exec('PRAGMA foreign_keys = ON')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// User fixtures
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
interface TestUser {
|
|
137
|
+
id: string
|
|
138
|
+
username: string
|
|
139
|
+
role: string
|
|
140
|
+
accessToken: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createUserDirectly(
|
|
144
|
+
db: Database,
|
|
145
|
+
opts: { username: string; role: string },
|
|
146
|
+
): TestUser {
|
|
147
|
+
const id = `usr_${randomUUID().replace(/-/g, '')}`
|
|
148
|
+
const now = new Date().toISOString()
|
|
149
|
+
// Use a pre-computed hash for speed (argon2id is slow in tests).
|
|
150
|
+
// We insert directly — the auth-routes /login verifies via verifyPassword.
|
|
151
|
+
const passwordHash =
|
|
152
|
+
'$argon2id$v=19$m=65536,t=3,p=1$dGVzdHNhbHR0ZXN0$' +
|
|
153
|
+
'dGVzdGhhc2h0ZXN0aGFzaHRlc3RoYXNodGVzdGhhc2g'
|
|
154
|
+
db.query(
|
|
155
|
+
`INSERT INTO users (id, username, password_hash, role, created_at)
|
|
156
|
+
VALUES ($id, $username, $hash, $role, $now)`,
|
|
157
|
+
).run({
|
|
158
|
+
$id: id,
|
|
159
|
+
$username: opts.username,
|
|
160
|
+
$hash: passwordHash,
|
|
161
|
+
$role: opts.role,
|
|
162
|
+
$now: now,
|
|
163
|
+
})
|
|
164
|
+
return { id, username: opts.username, role: opts.role, accessToken: '' }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function issueTokenForUser(db: Database, userId: string): string {
|
|
168
|
+
// Import dynamically to avoid hard dependency
|
|
169
|
+
try {
|
|
170
|
+
// Use the session module to issue tokens
|
|
171
|
+
const { issueSessionToken } = require('../auth/session') as {
|
|
172
|
+
issueSessionToken: (
|
|
173
|
+
userId: string,
|
|
174
|
+
db: Database,
|
|
175
|
+
) => { accessToken: string; refreshToken: string }
|
|
176
|
+
}
|
|
177
|
+
const { accessToken } = issueSessionToken(userId, db)
|
|
178
|
+
return accessToken
|
|
179
|
+
} catch {
|
|
180
|
+
// Fallback: insert a token manually
|
|
181
|
+
const token = `rct_${randomUUID().replace(/-/g, '')}`
|
|
182
|
+
const tokenHash = createHash('sha256').update(token).digest('hex')
|
|
183
|
+
const now = new Date().toISOString()
|
|
184
|
+
const expiresAt = new Date(Date.now() + 3600_000).toISOString()
|
|
185
|
+
db.query(
|
|
186
|
+
`INSERT INTO session_tokens (token_hash, user_id, kind, expires_at, created_at)
|
|
187
|
+
VALUES ($hash, $userId, 'access', $exp, $now)`,
|
|
188
|
+
).run({ $hash: tokenHash, $userId: userId, $exp: expiresAt, $now: now })
|
|
189
|
+
return token
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function setupAdmin(app: Hono): Promise<TestUser> {
|
|
194
|
+
const res = await app.request('/web/auth/setup', {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/json' },
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
apiKey: 'test-api-key',
|
|
199
|
+
username: 'admin',
|
|
200
|
+
password: 'admin-password-123',
|
|
201
|
+
}),
|
|
202
|
+
})
|
|
203
|
+
let body: Record<string, string> = {}
|
|
204
|
+
try {
|
|
205
|
+
body = (await res.json()) as Record<string, string>
|
|
206
|
+
} catch {
|
|
207
|
+
// Response may not be valid JSON
|
|
208
|
+
}
|
|
209
|
+
const db = getDb!()
|
|
210
|
+
const adminRow = db
|
|
211
|
+
.query("SELECT id FROM users WHERE username = 'admin'")
|
|
212
|
+
.get() as { id: string } | null
|
|
213
|
+
const adminId =
|
|
214
|
+
adminRow?.id || `usr_admin_${randomUUID().replace(/-/g, '').slice(0, 8)}`
|
|
215
|
+
// Ensure admin has a valid access token
|
|
216
|
+
const accessToken =
|
|
217
|
+
body.accessToken || body.access_token || issueTokenForUser(db, adminId)
|
|
218
|
+
return {
|
|
219
|
+
id: adminId,
|
|
220
|
+
username: 'admin',
|
|
221
|
+
role: 'admin',
|
|
222
|
+
accessToken,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function createAndLoginUser(
|
|
227
|
+
db: Database,
|
|
228
|
+
app: Hono,
|
|
229
|
+
opts: { username: string; role: string },
|
|
230
|
+
): Promise<TestUser> {
|
|
231
|
+
const user = createUserDirectly(db, opts)
|
|
232
|
+
user.accessToken = issueTokenForUser(db, user.id)
|
|
233
|
+
return user
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Team fixtures
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
function createTeamDirectly(
|
|
241
|
+
db: Database,
|
|
242
|
+
opts: { name: string; slug: string; createdBy: string },
|
|
243
|
+
): { id: string; name: string; slug: string } {
|
|
244
|
+
const id = `team_${randomUUID().replace(/-/g, '')}`
|
|
245
|
+
const now = new Date().toISOString()
|
|
246
|
+
db.query(
|
|
247
|
+
`INSERT INTO teams (id, name, slug, created_by, created_at, updated_at)
|
|
248
|
+
VALUES ($id, $name, $slug, $createdBy, $now, $now)`,
|
|
249
|
+
).run({
|
|
250
|
+
$id: id,
|
|
251
|
+
$name: opts.name,
|
|
252
|
+
$slug: opts.slug,
|
|
253
|
+
$createdBy: opts.createdBy,
|
|
254
|
+
$now: now,
|
|
255
|
+
})
|
|
256
|
+
// Add creator as owner
|
|
257
|
+
db.query(
|
|
258
|
+
`INSERT INTO team_members (team_id, user_id, role, added_at, added_by)
|
|
259
|
+
VALUES ($teamId, $userId, 'owner', $now, $addedBy)`,
|
|
260
|
+
).run({
|
|
261
|
+
$teamId: id,
|
|
262
|
+
$userId: opts.createdBy,
|
|
263
|
+
$addedBy: opts.createdBy,
|
|
264
|
+
$now: now,
|
|
265
|
+
})
|
|
266
|
+
return { id, name: opts.name, slug: opts.slug }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function addTeamMember(
|
|
270
|
+
db: Database,
|
|
271
|
+
opts: { teamId: string; userId: string; role: string; addedBy: string },
|
|
272
|
+
): void {
|
|
273
|
+
const now = new Date().toISOString()
|
|
274
|
+
db.query(
|
|
275
|
+
`INSERT INTO team_members (team_id, user_id, role, added_at, added_by)
|
|
276
|
+
VALUES ($teamId, $userId, $role, $now, $addedBy)`,
|
|
277
|
+
).run({
|
|
278
|
+
$teamId: opts.teamId,
|
|
279
|
+
$userId: opts.userId,
|
|
280
|
+
$role: opts.role,
|
|
281
|
+
$addedBy: opts.addedBy,
|
|
282
|
+
$now: now,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function authHeader(token: string): Record<string, string> {
|
|
287
|
+
return { Authorization: `Bearer ${token}` }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ===========================================================================
|
|
291
|
+
// TESTS
|
|
292
|
+
// ===========================================================================
|
|
293
|
+
|
|
294
|
+
describe('POST /web/teams', () => {
|
|
295
|
+
let app: Hono
|
|
296
|
+
let db: Database
|
|
297
|
+
let admin: TestUser
|
|
298
|
+
let member: TestUser
|
|
299
|
+
let guest: TestUser
|
|
300
|
+
|
|
301
|
+
beforeEach(async () => {
|
|
302
|
+
db = freshDb()
|
|
303
|
+
app = createApp()
|
|
304
|
+
admin = await setupAdmin(app)
|
|
305
|
+
member = await createAndLoginUser(db, app, {
|
|
306
|
+
username: 'member1',
|
|
307
|
+
role: 'member',
|
|
308
|
+
})
|
|
309
|
+
guest = await createAndLoginUser(db, app, {
|
|
310
|
+
username: 'guest1',
|
|
311
|
+
role: 'guest',
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test('system admin creates team → 201 + team info', async () => {
|
|
316
|
+
const res = await app.request('/web/teams', {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: {
|
|
319
|
+
...authHeader(admin.accessToken),
|
|
320
|
+
'Content-Type': 'application/json',
|
|
321
|
+
},
|
|
322
|
+
body: JSON.stringify({ name: 'Engineering', slug: 'engineering' }),
|
|
323
|
+
})
|
|
324
|
+
expect(res.status).toBe(201)
|
|
325
|
+
const body = (await res.json()) as Record<string, unknown>
|
|
326
|
+
expect(body.name).toBe('Engineering')
|
|
327
|
+
expect(body.slug).toBe('engineering')
|
|
328
|
+
expect(body.id).toBeDefined()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('member user creates team → 201 + auto-becomes owner', async () => {
|
|
332
|
+
const res = await app.request('/web/teams', {
|
|
333
|
+
method: 'POST',
|
|
334
|
+
headers: {
|
|
335
|
+
...authHeader(member.accessToken),
|
|
336
|
+
'Content-Type': 'application/json',
|
|
337
|
+
},
|
|
338
|
+
body: JSON.stringify({ name: 'My Team', slug: 'my-team' }),
|
|
339
|
+
})
|
|
340
|
+
expect(res.status).toBe(201)
|
|
341
|
+
const body = (await res.json()) as Record<string, unknown>
|
|
342
|
+
expect(body.id).toBeDefined()
|
|
343
|
+
|
|
344
|
+
// Verify the member is an owner in team_members
|
|
345
|
+
const membership = db
|
|
346
|
+
.query(
|
|
347
|
+
`SELECT role FROM team_members WHERE team_id = $teamId AND user_id = $userId`,
|
|
348
|
+
)
|
|
349
|
+
.get({ $teamId: body.id as string, $userId: member.id }) as {
|
|
350
|
+
role: string
|
|
351
|
+
} | null
|
|
352
|
+
expect(membership).not.toBeNull()
|
|
353
|
+
expect(membership?.role).toBe('owner')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('guest user creates team → 403', async () => {
|
|
357
|
+
const res = await app.request('/web/teams', {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: {
|
|
360
|
+
...authHeader(guest.accessToken),
|
|
361
|
+
'Content-Type': 'application/json',
|
|
362
|
+
},
|
|
363
|
+
body: JSON.stringify({ name: 'Guest Team', slug: 'guest-team' }),
|
|
364
|
+
})
|
|
365
|
+
expect(res.status).toBe(403)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('unauthenticated → 401', async () => {
|
|
369
|
+
const res = await app.request('/web/teams', {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
headers: { 'Content-Type': 'application/json' },
|
|
372
|
+
body: JSON.stringify({ name: 'Anon Team', slug: 'anon-team' }),
|
|
373
|
+
})
|
|
374
|
+
expect(res.status).toBe(401)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('duplicate slug → 409', async () => {
|
|
378
|
+
// Create first team
|
|
379
|
+
await app.request('/web/teams', {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: {
|
|
382
|
+
...authHeader(admin.accessToken),
|
|
383
|
+
'Content-Type': 'application/json',
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify({ name: 'First', slug: 'dupe-slug' }),
|
|
386
|
+
})
|
|
387
|
+
// Try creating with same slug
|
|
388
|
+
const res = await app.request('/web/teams', {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers: {
|
|
391
|
+
...authHeader(admin.accessToken),
|
|
392
|
+
'Content-Type': 'application/json',
|
|
393
|
+
},
|
|
394
|
+
body: JSON.stringify({ name: 'Second', slug: 'dupe-slug' }),
|
|
395
|
+
})
|
|
396
|
+
expect(res.status).toBe(409)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('invalid slug formats → 400', async () => {
|
|
400
|
+
const invalidSlugs = [
|
|
401
|
+
'UpperCase', // uppercase not allowed
|
|
402
|
+
'has spaces', // spaces not allowed
|
|
403
|
+
'sp@cial!', // special characters
|
|
404
|
+
'ab', // too short (<3)
|
|
405
|
+
'a'.repeat(33), // too long (>32)
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
for (const slug of invalidSlugs) {
|
|
409
|
+
const res = await app.request('/web/teams', {
|
|
410
|
+
method: 'POST',
|
|
411
|
+
headers: {
|
|
412
|
+
...authHeader(admin.accessToken),
|
|
413
|
+
'Content-Type': 'application/json',
|
|
414
|
+
},
|
|
415
|
+
body: JSON.stringify({ name: 'Test', slug }),
|
|
416
|
+
})
|
|
417
|
+
expect(res.status).toBe(400)
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test('missing name or slug → 400', async () => {
|
|
422
|
+
// Missing slug
|
|
423
|
+
const res1 = await app.request('/web/teams', {
|
|
424
|
+
method: 'POST',
|
|
425
|
+
headers: {
|
|
426
|
+
...authHeader(admin.accessToken),
|
|
427
|
+
'Content-Type': 'application/json',
|
|
428
|
+
},
|
|
429
|
+
body: JSON.stringify({ name: 'Only Name' }),
|
|
430
|
+
})
|
|
431
|
+
expect(res1.status).toBe(400)
|
|
432
|
+
|
|
433
|
+
// Missing name
|
|
434
|
+
const res2 = await app.request('/web/teams', {
|
|
435
|
+
method: 'POST',
|
|
436
|
+
headers: {
|
|
437
|
+
...authHeader(admin.accessToken),
|
|
438
|
+
'Content-Type': 'application/json',
|
|
439
|
+
},
|
|
440
|
+
body: JSON.stringify({ slug: 'only-slug' }),
|
|
441
|
+
})
|
|
442
|
+
expect(res2.status).toBe(400)
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
describe('GET /web/teams', () => {
|
|
447
|
+
let app: Hono
|
|
448
|
+
let db: Database
|
|
449
|
+
let admin: TestUser
|
|
450
|
+
let member: TestUser
|
|
451
|
+
let guest: TestUser
|
|
452
|
+
|
|
453
|
+
beforeEach(async () => {
|
|
454
|
+
db = freshDb()
|
|
455
|
+
app = createApp()
|
|
456
|
+
admin = await setupAdmin(app)
|
|
457
|
+
member = await createAndLoginUser(db, app, {
|
|
458
|
+
username: 'member1',
|
|
459
|
+
role: 'member',
|
|
460
|
+
})
|
|
461
|
+
guest = await createAndLoginUser(db, app, {
|
|
462
|
+
username: 'guest1',
|
|
463
|
+
role: 'guest',
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// Create two teams: member belongs to one
|
|
467
|
+
const team1 = createTeamDirectly(db, {
|
|
468
|
+
name: 'Team Alpha',
|
|
469
|
+
slug: 'alpha',
|
|
470
|
+
createdBy: admin.id,
|
|
471
|
+
})
|
|
472
|
+
const team2 = createTeamDirectly(db, {
|
|
473
|
+
name: 'Team Beta',
|
|
474
|
+
slug: 'beta',
|
|
475
|
+
createdBy: member.id,
|
|
476
|
+
})
|
|
477
|
+
// member is owner of team2 (auto-added by createTeamDirectly)
|
|
478
|
+
// member is NOT in team1
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('system admin → returns all teams', async () => {
|
|
482
|
+
const res = await app.request('/web/teams', {
|
|
483
|
+
headers: authHeader(admin.accessToken),
|
|
484
|
+
})
|
|
485
|
+
expect(res.status).toBe(200)
|
|
486
|
+
const body = (await res.json()) as Array<Record<string, unknown>>
|
|
487
|
+
expect(body.length).toBeGreaterThanOrEqual(2)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
test('member user → returns only teams they belong to', async () => {
|
|
491
|
+
const res = await app.request('/web/teams', {
|
|
492
|
+
headers: authHeader(member.accessToken),
|
|
493
|
+
})
|
|
494
|
+
expect(res.status).toBe(200)
|
|
495
|
+
const body = (await res.json()) as Array<Record<string, unknown>>
|
|
496
|
+
// member is in team2 (beta) but not team1 (alpha)
|
|
497
|
+
expect(body.length).toBe(1)
|
|
498
|
+
expect(body[0].slug).toBe('beta')
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('guest → returns empty list', async () => {
|
|
502
|
+
const res = await app.request('/web/teams', {
|
|
503
|
+
headers: authHeader(guest.accessToken),
|
|
504
|
+
})
|
|
505
|
+
expect(res.status).toBe(200)
|
|
506
|
+
const body = (await res.json()) as Array<Record<string, unknown>>
|
|
507
|
+
expect(body.length).toBe(0)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test('unauthenticated → 401', async () => {
|
|
511
|
+
const res = await app.request('/web/teams')
|
|
512
|
+
expect(res.status).toBe(401)
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
describe('GET /web/teams/:id', () => {
|
|
517
|
+
let app: Hono
|
|
518
|
+
let db: Database
|
|
519
|
+
let admin: TestUser
|
|
520
|
+
let member: TestUser
|
|
521
|
+
let outsider: TestUser
|
|
522
|
+
let team: { id: string; name: string; slug: string }
|
|
523
|
+
|
|
524
|
+
beforeEach(async () => {
|
|
525
|
+
db = freshDb()
|
|
526
|
+
app = createApp()
|
|
527
|
+
admin = await setupAdmin(app)
|
|
528
|
+
member = await createAndLoginUser(db, app, {
|
|
529
|
+
username: 'member1',
|
|
530
|
+
role: 'member',
|
|
531
|
+
})
|
|
532
|
+
outsider = await createAndLoginUser(db, app, {
|
|
533
|
+
username: 'outsider',
|
|
534
|
+
role: 'member',
|
|
535
|
+
})
|
|
536
|
+
team = createTeamDirectly(db, {
|
|
537
|
+
name: 'My Team',
|
|
538
|
+
slug: 'my-team',
|
|
539
|
+
createdBy: member.id,
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test('team member → 200', async () => {
|
|
544
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
545
|
+
headers: authHeader(member.accessToken),
|
|
546
|
+
})
|
|
547
|
+
expect(res.status).toBe(200)
|
|
548
|
+
const body = (await res.json()) as Record<string, unknown>
|
|
549
|
+
expect(body.id).toBe(team.id)
|
|
550
|
+
expect(body.name).toBe('My Team')
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('non-team member → 403', async () => {
|
|
554
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
555
|
+
headers: authHeader(outsider.accessToken),
|
|
556
|
+
})
|
|
557
|
+
expect(res.status).toBe(403)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test('system admin → 200 (any team)', async () => {
|
|
561
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
562
|
+
headers: authHeader(admin.accessToken),
|
|
563
|
+
})
|
|
564
|
+
expect(res.status).toBe(200)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
test('non-existent team → 404', async () => {
|
|
568
|
+
const res = await app.request('/web/teams/team_nonexistent', {
|
|
569
|
+
headers: authHeader(member.accessToken),
|
|
570
|
+
})
|
|
571
|
+
expect(res.status).toBe(404)
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
describe('PATCH /web/teams/:id', () => {
|
|
576
|
+
let app: Hono
|
|
577
|
+
let db: Database
|
|
578
|
+
let admin: TestUser
|
|
579
|
+
let owner: TestUser
|
|
580
|
+
let teamAdmin: TestUser
|
|
581
|
+
let teamMember: TestUser
|
|
582
|
+
let team: { id: string }
|
|
583
|
+
|
|
584
|
+
beforeEach(async () => {
|
|
585
|
+
db = freshDb()
|
|
586
|
+
app = createApp()
|
|
587
|
+
admin = await setupAdmin(app)
|
|
588
|
+
owner = await createAndLoginUser(db, app, {
|
|
589
|
+
username: 'owner1',
|
|
590
|
+
role: 'member',
|
|
591
|
+
})
|
|
592
|
+
teamAdmin = await createAndLoginUser(db, app, {
|
|
593
|
+
username: 'tadmin',
|
|
594
|
+
role: 'member',
|
|
595
|
+
})
|
|
596
|
+
teamMember = await createAndLoginUser(db, app, {
|
|
597
|
+
username: 'tmember',
|
|
598
|
+
role: 'member',
|
|
599
|
+
})
|
|
600
|
+
team = createTeamDirectly(db, {
|
|
601
|
+
name: 'Orig Name',
|
|
602
|
+
slug: 'orig-slug',
|
|
603
|
+
createdBy: owner.id,
|
|
604
|
+
})
|
|
605
|
+
addTeamMember(db, {
|
|
606
|
+
teamId: team.id,
|
|
607
|
+
userId: teamAdmin.id,
|
|
608
|
+
role: 'admin',
|
|
609
|
+
addedBy: owner.id,
|
|
610
|
+
})
|
|
611
|
+
addTeamMember(db, {
|
|
612
|
+
teamId: team.id,
|
|
613
|
+
userId: teamMember.id,
|
|
614
|
+
role: 'member',
|
|
615
|
+
addedBy: owner.id,
|
|
616
|
+
})
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test('team owner → 200', async () => {
|
|
620
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
621
|
+
method: 'PATCH',
|
|
622
|
+
headers: {
|
|
623
|
+
...authHeader(owner.accessToken),
|
|
624
|
+
'Content-Type': 'application/json',
|
|
625
|
+
},
|
|
626
|
+
body: JSON.stringify({ name: 'Updated Name' }),
|
|
627
|
+
})
|
|
628
|
+
expect(res.status).toBe(200)
|
|
629
|
+
const body = (await res.json()) as Record<string, unknown>
|
|
630
|
+
expect(body.name).toBe('Updated Name')
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
test('team admin → 403 (only owner can modify team info)', async () => {
|
|
634
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
635
|
+
method: 'PATCH',
|
|
636
|
+
headers: {
|
|
637
|
+
...authHeader(teamAdmin.accessToken),
|
|
638
|
+
'Content-Type': 'application/json',
|
|
639
|
+
},
|
|
640
|
+
body: JSON.stringify({ name: 'Admin Change' }),
|
|
641
|
+
})
|
|
642
|
+
expect(res.status).toBe(403)
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
test('team member → 403', async () => {
|
|
646
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
647
|
+
method: 'PATCH',
|
|
648
|
+
headers: {
|
|
649
|
+
...authHeader(teamMember.accessToken),
|
|
650
|
+
'Content-Type': 'application/json',
|
|
651
|
+
},
|
|
652
|
+
body: JSON.stringify({ name: 'Member Change' }),
|
|
653
|
+
})
|
|
654
|
+
expect(res.status).toBe(403)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
test('system admin → 200', async () => {
|
|
658
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
659
|
+
method: 'PATCH',
|
|
660
|
+
headers: {
|
|
661
|
+
...authHeader(admin.accessToken),
|
|
662
|
+
'Content-Type': 'application/json',
|
|
663
|
+
},
|
|
664
|
+
body: JSON.stringify({ name: 'Admin Override' }),
|
|
665
|
+
})
|
|
666
|
+
expect(res.status).toBe(200)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test('modify slug to existing → 409', async () => {
|
|
670
|
+
// Create another team with slug "taken-slug"
|
|
671
|
+
createTeamDirectly(db, {
|
|
672
|
+
name: 'Other',
|
|
673
|
+
slug: 'taken-slug',
|
|
674
|
+
createdBy: admin.id,
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
678
|
+
method: 'PATCH',
|
|
679
|
+
headers: {
|
|
680
|
+
...authHeader(owner.accessToken),
|
|
681
|
+
'Content-Type': 'application/json',
|
|
682
|
+
},
|
|
683
|
+
body: JSON.stringify({ slug: 'taken-slug' }),
|
|
684
|
+
})
|
|
685
|
+
expect(res.status).toBe(409)
|
|
686
|
+
})
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
describe('DELETE /web/teams/:id', () => {
|
|
690
|
+
let app: Hono
|
|
691
|
+
let db: Database
|
|
692
|
+
let admin: TestUser
|
|
693
|
+
let owner: TestUser
|
|
694
|
+
let teamAdmin: TestUser
|
|
695
|
+
let teamMember: TestUser
|
|
696
|
+
let team: { id: string }
|
|
697
|
+
|
|
698
|
+
beforeEach(async () => {
|
|
699
|
+
db = freshDb()
|
|
700
|
+
app = createApp()
|
|
701
|
+
admin = await setupAdmin(app)
|
|
702
|
+
owner = await createAndLoginUser(db, app, {
|
|
703
|
+
username: 'owner1',
|
|
704
|
+
role: 'member',
|
|
705
|
+
})
|
|
706
|
+
teamAdmin = await createAndLoginUser(db, app, {
|
|
707
|
+
username: 'tadmin',
|
|
708
|
+
role: 'member',
|
|
709
|
+
})
|
|
710
|
+
teamMember = await createAndLoginUser(db, app, {
|
|
711
|
+
username: 'tmember',
|
|
712
|
+
role: 'member',
|
|
713
|
+
})
|
|
714
|
+
team = createTeamDirectly(db, {
|
|
715
|
+
name: 'Delete Me',
|
|
716
|
+
slug: 'delete-me',
|
|
717
|
+
createdBy: owner.id,
|
|
718
|
+
})
|
|
719
|
+
addTeamMember(db, {
|
|
720
|
+
teamId: team.id,
|
|
721
|
+
userId: teamAdmin.id,
|
|
722
|
+
role: 'admin',
|
|
723
|
+
addedBy: owner.id,
|
|
724
|
+
})
|
|
725
|
+
addTeamMember(db, {
|
|
726
|
+
teamId: team.id,
|
|
727
|
+
userId: teamMember.id,
|
|
728
|
+
role: 'member',
|
|
729
|
+
addedBy: owner.id,
|
|
730
|
+
})
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
test('team owner → 200 + sets deleted_at', async () => {
|
|
734
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
735
|
+
method: 'DELETE',
|
|
736
|
+
headers: authHeader(owner.accessToken),
|
|
737
|
+
})
|
|
738
|
+
expect(res.status).toBe(200)
|
|
739
|
+
|
|
740
|
+
// Verify soft delete in DB
|
|
741
|
+
const row = db.query('SELECT deleted_at FROM teams WHERE id = $id').get({
|
|
742
|
+
$id: team.id,
|
|
743
|
+
}) as { deleted_at: string | null } | null
|
|
744
|
+
expect(row?.deleted_at).not.toBeNull()
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
test('team admin → 403', async () => {
|
|
748
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
749
|
+
method: 'DELETE',
|
|
750
|
+
headers: authHeader(teamAdmin.accessToken),
|
|
751
|
+
})
|
|
752
|
+
expect(res.status).toBe(403)
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
test('team member → 403', async () => {
|
|
756
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
757
|
+
method: 'DELETE',
|
|
758
|
+
headers: authHeader(teamMember.accessToken),
|
|
759
|
+
})
|
|
760
|
+
expect(res.status).toBe(403)
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
test('system admin → 200', async () => {
|
|
764
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
765
|
+
method: 'DELETE',
|
|
766
|
+
headers: authHeader(admin.accessToken),
|
|
767
|
+
})
|
|
768
|
+
expect(res.status).toBe(200)
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
test('deleted team → GET /web/teams/:id returns 404', async () => {
|
|
772
|
+
await app.request(`/web/teams/${team.id}`, {
|
|
773
|
+
method: 'DELETE',
|
|
774
|
+
headers: authHeader(owner.accessToken),
|
|
775
|
+
})
|
|
776
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
777
|
+
headers: authHeader(owner.accessToken),
|
|
778
|
+
})
|
|
779
|
+
expect(res.status).toBe(404)
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
test('deleted team → not in GET /web/teams list', async () => {
|
|
783
|
+
await app.request(`/web/teams/${team.id}`, {
|
|
784
|
+
method: 'DELETE',
|
|
785
|
+
headers: authHeader(owner.accessToken),
|
|
786
|
+
})
|
|
787
|
+
const res = await app.request('/web/teams', {
|
|
788
|
+
headers: authHeader(owner.accessToken),
|
|
789
|
+
})
|
|
790
|
+
const body = (await res.json()) as Array<Record<string, unknown>>
|
|
791
|
+
const found = body.find(t => t.id === team.id)
|
|
792
|
+
expect(found).toBeUndefined()
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
test('team with un-transferred environments → 400', async () => {
|
|
796
|
+
// Create an environment linked to this team
|
|
797
|
+
const envId = `env_${randomUUID().replace(/-/g, '')}`
|
|
798
|
+
try {
|
|
799
|
+
db.query(
|
|
800
|
+
`INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`,
|
|
801
|
+
).run({ $teamId: team.id, $envId: envId })
|
|
802
|
+
} catch {
|
|
803
|
+
// Table may not exist yet — test will still fail as expected
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
807
|
+
method: 'DELETE',
|
|
808
|
+
headers: authHeader(owner.accessToken),
|
|
809
|
+
})
|
|
810
|
+
// Should be 400 because there are environments that need to be transferred first
|
|
811
|
+
expect(res.status).toBe(400)
|
|
812
|
+
})
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
// ===========================================================================
|
|
816
|
+
// Team member management
|
|
817
|
+
// ===========================================================================
|
|
818
|
+
|
|
819
|
+
describe('GET /web/teams/:id/members', () => {
|
|
820
|
+
let app: Hono
|
|
821
|
+
let db: Database
|
|
822
|
+
let admin: TestUser
|
|
823
|
+
let owner: TestUser
|
|
824
|
+
let member: TestUser
|
|
825
|
+
let outsider: TestUser
|
|
826
|
+
let team: { id: string }
|
|
827
|
+
|
|
828
|
+
beforeEach(async () => {
|
|
829
|
+
db = freshDb()
|
|
830
|
+
app = createApp()
|
|
831
|
+
admin = await setupAdmin(app)
|
|
832
|
+
owner = await createAndLoginUser(db, app, {
|
|
833
|
+
username: 'owner1',
|
|
834
|
+
role: 'member',
|
|
835
|
+
})
|
|
836
|
+
member = await createAndLoginUser(db, app, {
|
|
837
|
+
username: 'member1',
|
|
838
|
+
role: 'member',
|
|
839
|
+
})
|
|
840
|
+
outsider = await createAndLoginUser(db, app, {
|
|
841
|
+
username: 'outsider',
|
|
842
|
+
role: 'member',
|
|
843
|
+
})
|
|
844
|
+
team = createTeamDirectly(db, {
|
|
845
|
+
name: 'Members Team',
|
|
846
|
+
slug: 'members-team',
|
|
847
|
+
createdBy: owner.id,
|
|
848
|
+
})
|
|
849
|
+
addTeamMember(db, {
|
|
850
|
+
teamId: team.id,
|
|
851
|
+
userId: member.id,
|
|
852
|
+
role: 'member',
|
|
853
|
+
addedBy: owner.id,
|
|
854
|
+
})
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
test('team member → 200 + member list', async () => {
|
|
858
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
859
|
+
headers: authHeader(member.accessToken),
|
|
860
|
+
})
|
|
861
|
+
expect(res.status).toBe(200)
|
|
862
|
+
const body = (await res.json()) as Array<Record<string, unknown>>
|
|
863
|
+
expect(body.length).toBeGreaterThanOrEqual(2)
|
|
864
|
+
const userIds = body.map(m => m.user_id || m.userId)
|
|
865
|
+
expect(userIds).toContain(owner.id)
|
|
866
|
+
expect(userIds).toContain(member.id)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
test('non-team member → 403', async () => {
|
|
870
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
871
|
+
headers: authHeader(outsider.accessToken),
|
|
872
|
+
})
|
|
873
|
+
expect(res.status).toBe(403)
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
test('system admin → 200', async () => {
|
|
877
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
878
|
+
headers: authHeader(admin.accessToken),
|
|
879
|
+
})
|
|
880
|
+
expect(res.status).toBe(200)
|
|
881
|
+
})
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
describe('POST /web/teams/:id/members', () => {
|
|
885
|
+
let app: Hono
|
|
886
|
+
let db: Database
|
|
887
|
+
let admin: TestUser
|
|
888
|
+
let owner: TestUser
|
|
889
|
+
let teamAdmin: TestUser
|
|
890
|
+
let teamMember: TestUser
|
|
891
|
+
let newUser: TestUser
|
|
892
|
+
let team: { id: string }
|
|
893
|
+
|
|
894
|
+
beforeEach(async () => {
|
|
895
|
+
db = freshDb()
|
|
896
|
+
app = createApp()
|
|
897
|
+
admin = await setupAdmin(app)
|
|
898
|
+
owner = await createAndLoginUser(db, app, {
|
|
899
|
+
username: 'owner1',
|
|
900
|
+
role: 'member',
|
|
901
|
+
})
|
|
902
|
+
teamAdmin = await createAndLoginUser(db, app, {
|
|
903
|
+
username: 'tadmin',
|
|
904
|
+
role: 'member',
|
|
905
|
+
})
|
|
906
|
+
teamMember = await createAndLoginUser(db, app, {
|
|
907
|
+
username: 'tmember',
|
|
908
|
+
role: 'member',
|
|
909
|
+
})
|
|
910
|
+
newUser = await createAndLoginUser(db, app, {
|
|
911
|
+
username: 'newbie',
|
|
912
|
+
role: 'member',
|
|
913
|
+
})
|
|
914
|
+
team = createTeamDirectly(db, {
|
|
915
|
+
name: 'Add Team',
|
|
916
|
+
slug: 'add-team',
|
|
917
|
+
createdBy: owner.id,
|
|
918
|
+
})
|
|
919
|
+
addTeamMember(db, {
|
|
920
|
+
teamId: team.id,
|
|
921
|
+
userId: teamAdmin.id,
|
|
922
|
+
role: 'admin',
|
|
923
|
+
addedBy: owner.id,
|
|
924
|
+
})
|
|
925
|
+
addTeamMember(db, {
|
|
926
|
+
teamId: team.id,
|
|
927
|
+
userId: teamMember.id,
|
|
928
|
+
role: 'member',
|
|
929
|
+
addedBy: owner.id,
|
|
930
|
+
})
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
test('team owner/admin → 201', async () => {
|
|
934
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
935
|
+
method: 'POST',
|
|
936
|
+
headers: {
|
|
937
|
+
...authHeader(owner.accessToken),
|
|
938
|
+
'Content-Type': 'application/json',
|
|
939
|
+
},
|
|
940
|
+
body: JSON.stringify({ userId: newUser.id, role: 'member' }),
|
|
941
|
+
})
|
|
942
|
+
expect(res.status).toBe(201)
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
test('team member → 403', async () => {
|
|
946
|
+
const anotherUser = await createAndLoginUser(db, app, {
|
|
947
|
+
username: 'another',
|
|
948
|
+
role: 'member',
|
|
949
|
+
})
|
|
950
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
951
|
+
method: 'POST',
|
|
952
|
+
headers: {
|
|
953
|
+
...authHeader(teamMember.accessToken),
|
|
954
|
+
'Content-Type': 'application/json',
|
|
955
|
+
},
|
|
956
|
+
body: JSON.stringify({ userId: anotherUser.id, role: 'member' }),
|
|
957
|
+
})
|
|
958
|
+
expect(res.status).toBe(403)
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
test('system admin → 201', async () => {
|
|
962
|
+
const anotherUser = await createAndLoginUser(db, app, {
|
|
963
|
+
username: 'admin-add',
|
|
964
|
+
role: 'member',
|
|
965
|
+
})
|
|
966
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
967
|
+
method: 'POST',
|
|
968
|
+
headers: {
|
|
969
|
+
...authHeader(admin.accessToken),
|
|
970
|
+
'Content-Type': 'application/json',
|
|
971
|
+
},
|
|
972
|
+
body: JSON.stringify({ userId: anotherUser.id, role: 'member' }),
|
|
973
|
+
})
|
|
974
|
+
expect(res.status).toBe(201)
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
test('add already-existing member → 409', async () => {
|
|
978
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
979
|
+
method: 'POST',
|
|
980
|
+
headers: {
|
|
981
|
+
...authHeader(owner.accessToken),
|
|
982
|
+
'Content-Type': 'application/json',
|
|
983
|
+
},
|
|
984
|
+
body: JSON.stringify({ userId: teamMember.id, role: 'member' }),
|
|
985
|
+
})
|
|
986
|
+
expect(res.status).toBe(409)
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
test('add non-existent user → 404', async () => {
|
|
990
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
991
|
+
method: 'POST',
|
|
992
|
+
headers: {
|
|
993
|
+
...authHeader(owner.accessToken),
|
|
994
|
+
'Content-Type': 'application/json',
|
|
995
|
+
},
|
|
996
|
+
body: JSON.stringify({ userId: 'usr_nonexistent', role: 'member' }),
|
|
997
|
+
})
|
|
998
|
+
expect(res.status).toBe(404)
|
|
999
|
+
})
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
describe('PATCH /web/teams/:id/members/:userId', () => {
|
|
1003
|
+
let app: Hono
|
|
1004
|
+
let db: Database
|
|
1005
|
+
let admin: TestUser
|
|
1006
|
+
let owner: TestUser
|
|
1007
|
+
let teamAdmin: TestUser
|
|
1008
|
+
let teamMember: TestUser
|
|
1009
|
+
let team: { id: string }
|
|
1010
|
+
|
|
1011
|
+
beforeEach(async () => {
|
|
1012
|
+
db = freshDb()
|
|
1013
|
+
app = createApp()
|
|
1014
|
+
admin = await setupAdmin(app)
|
|
1015
|
+
owner = await createAndLoginUser(db, app, {
|
|
1016
|
+
username: 'owner1',
|
|
1017
|
+
role: 'member',
|
|
1018
|
+
})
|
|
1019
|
+
teamAdmin = await createAndLoginUser(db, app, {
|
|
1020
|
+
username: 'tadmin',
|
|
1021
|
+
role: 'member',
|
|
1022
|
+
})
|
|
1023
|
+
teamMember = await createAndLoginUser(db, app, {
|
|
1024
|
+
username: 'tmember',
|
|
1025
|
+
role: 'member',
|
|
1026
|
+
})
|
|
1027
|
+
team = createTeamDirectly(db, {
|
|
1028
|
+
name: 'Role Team',
|
|
1029
|
+
slug: 'role-team',
|
|
1030
|
+
createdBy: owner.id,
|
|
1031
|
+
})
|
|
1032
|
+
addTeamMember(db, {
|
|
1033
|
+
teamId: team.id,
|
|
1034
|
+
userId: teamAdmin.id,
|
|
1035
|
+
role: 'admin',
|
|
1036
|
+
addedBy: owner.id,
|
|
1037
|
+
})
|
|
1038
|
+
addTeamMember(db, {
|
|
1039
|
+
teamId: team.id,
|
|
1040
|
+
userId: teamMember.id,
|
|
1041
|
+
role: 'member',
|
|
1042
|
+
addedBy: owner.id,
|
|
1043
|
+
})
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
test('team owner → 200 (can change any member role)', async () => {
|
|
1047
|
+
const res = await app.request(
|
|
1048
|
+
`/web/teams/${team.id}/members/${teamMember.id}`,
|
|
1049
|
+
{
|
|
1050
|
+
method: 'PATCH',
|
|
1051
|
+
headers: {
|
|
1052
|
+
...authHeader(owner.accessToken),
|
|
1053
|
+
'Content-Type': 'application/json',
|
|
1054
|
+
},
|
|
1055
|
+
body: JSON.stringify({ role: 'admin' }),
|
|
1056
|
+
},
|
|
1057
|
+
)
|
|
1058
|
+
expect(res.status).toBe(200)
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
test('team admin → can change member but not owner/admin → 403 for admin', async () => {
|
|
1062
|
+
// Admin can change member
|
|
1063
|
+
const res1 = await app.request(
|
|
1064
|
+
`/web/teams/${team.id}/members/${teamMember.id}`,
|
|
1065
|
+
{
|
|
1066
|
+
method: 'PATCH',
|
|
1067
|
+
headers: {
|
|
1068
|
+
...authHeader(teamAdmin.accessToken),
|
|
1069
|
+
'Content-Type': 'application/json',
|
|
1070
|
+
},
|
|
1071
|
+
body: JSON.stringify({ role: 'admin' }),
|
|
1072
|
+
},
|
|
1073
|
+
)
|
|
1074
|
+
// This might be allowed or not depending on implementation — admin changing member role
|
|
1075
|
+
// The plan says: "team admin → can change member, cannot change owner/admin"
|
|
1076
|
+
// Changing a member to admin should be OK
|
|
1077
|
+
expect([200, 403]).toContain(res1.status)
|
|
1078
|
+
|
|
1079
|
+
// Admin cannot change owner role
|
|
1080
|
+
const res2 = await app.request(
|
|
1081
|
+
`/web/teams/${team.id}/members/${owner.id}`,
|
|
1082
|
+
{
|
|
1083
|
+
method: 'PATCH',
|
|
1084
|
+
headers: {
|
|
1085
|
+
...authHeader(teamAdmin.accessToken),
|
|
1086
|
+
'Content-Type': 'application/json',
|
|
1087
|
+
},
|
|
1088
|
+
body: JSON.stringify({ role: 'member' }),
|
|
1089
|
+
},
|
|
1090
|
+
)
|
|
1091
|
+
expect(res2.status).toBe(403)
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
test('system admin → 200', async () => {
|
|
1095
|
+
const res = await app.request(
|
|
1096
|
+
`/web/teams/${team.id}/members/${teamMember.id}`,
|
|
1097
|
+
{
|
|
1098
|
+
method: 'PATCH',
|
|
1099
|
+
headers: {
|
|
1100
|
+
...authHeader(admin.accessToken),
|
|
1101
|
+
'Content-Type': 'application/json',
|
|
1102
|
+
},
|
|
1103
|
+
body: JSON.stringify({ role: 'admin' }),
|
|
1104
|
+
},
|
|
1105
|
+
)
|
|
1106
|
+
expect(res.status).toBe(200)
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
test('owner self-demotes to member → 400 (avoid unmanaged team)', async () => {
|
|
1110
|
+
const res = await app.request(`/web/teams/${team.id}/members/${owner.id}`, {
|
|
1111
|
+
method: 'PATCH',
|
|
1112
|
+
headers: {
|
|
1113
|
+
...authHeader(owner.accessToken),
|
|
1114
|
+
'Content-Type': 'application/json',
|
|
1115
|
+
},
|
|
1116
|
+
body: JSON.stringify({ role: 'member' }),
|
|
1117
|
+
})
|
|
1118
|
+
expect(res.status).toBe(400)
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
test('last owner demotes → 400', async () => {
|
|
1122
|
+
// owner is the only owner. Try to demote them.
|
|
1123
|
+
const res = await app.request(`/web/teams/${team.id}/members/${owner.id}`, {
|
|
1124
|
+
method: 'PATCH',
|
|
1125
|
+
headers: {
|
|
1126
|
+
...authHeader(owner.accessToken),
|
|
1127
|
+
'Content-Type': 'application/json',
|
|
1128
|
+
},
|
|
1129
|
+
body: JSON.stringify({ role: 'admin' }),
|
|
1130
|
+
})
|
|
1131
|
+
// Demoting from owner to admin means no more owner — should be 400
|
|
1132
|
+
expect(res.status).toBe(400)
|
|
1133
|
+
})
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
describe('DELETE /web/teams/:id/members/:userId', () => {
|
|
1137
|
+
let app: Hono
|
|
1138
|
+
let db: Database
|
|
1139
|
+
let admin: TestUser
|
|
1140
|
+
let owner: TestUser
|
|
1141
|
+
let teamAdmin: TestUser
|
|
1142
|
+
let teamMember: TestUser
|
|
1143
|
+
let team: { id: string }
|
|
1144
|
+
|
|
1145
|
+
beforeEach(async () => {
|
|
1146
|
+
db = freshDb()
|
|
1147
|
+
app = createApp()
|
|
1148
|
+
admin = await setupAdmin(app)
|
|
1149
|
+
owner = await createAndLoginUser(db, app, {
|
|
1150
|
+
username: 'owner1',
|
|
1151
|
+
role: 'member',
|
|
1152
|
+
})
|
|
1153
|
+
teamAdmin = await createAndLoginUser(db, app, {
|
|
1154
|
+
username: 'tadmin',
|
|
1155
|
+
role: 'member',
|
|
1156
|
+
})
|
|
1157
|
+
teamMember = await createAndLoginUser(db, app, {
|
|
1158
|
+
username: 'tmember',
|
|
1159
|
+
role: 'member',
|
|
1160
|
+
})
|
|
1161
|
+
team = createTeamDirectly(db, {
|
|
1162
|
+
name: 'Remove Team',
|
|
1163
|
+
slug: 'remove-team',
|
|
1164
|
+
createdBy: owner.id,
|
|
1165
|
+
})
|
|
1166
|
+
addTeamMember(db, {
|
|
1167
|
+
teamId: team.id,
|
|
1168
|
+
userId: teamAdmin.id,
|
|
1169
|
+
role: 'admin',
|
|
1170
|
+
addedBy: owner.id,
|
|
1171
|
+
})
|
|
1172
|
+
addTeamMember(db, {
|
|
1173
|
+
teamId: team.id,
|
|
1174
|
+
userId: teamMember.id,
|
|
1175
|
+
role: 'member',
|
|
1176
|
+
addedBy: owner.id,
|
|
1177
|
+
})
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
test('team owner/admin → 200 (cannot remove self)', async () => {
|
|
1181
|
+
// Owner removes member
|
|
1182
|
+
const res = await app.request(
|
|
1183
|
+
`/web/teams/${team.id}/members/${teamMember.id}`,
|
|
1184
|
+
{
|
|
1185
|
+
method: 'DELETE',
|
|
1186
|
+
headers: authHeader(owner.accessToken),
|
|
1187
|
+
},
|
|
1188
|
+
)
|
|
1189
|
+
expect(res.status).toBe(200)
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
test('team member → can only remove self', async () => {
|
|
1193
|
+
// Member removes themselves
|
|
1194
|
+
const res1 = await app.request(
|
|
1195
|
+
`/web/teams/${team.id}/members/${teamMember.id}`,
|
|
1196
|
+
{
|
|
1197
|
+
method: 'DELETE',
|
|
1198
|
+
headers: authHeader(teamMember.accessToken),
|
|
1199
|
+
},
|
|
1200
|
+
)
|
|
1201
|
+
expect(res1.status).toBe(200)
|
|
1202
|
+
|
|
1203
|
+
// Member tries to remove another member (should fail)
|
|
1204
|
+
const anotherMember = await createAndLoginUser(db, app, {
|
|
1205
|
+
username: 'another',
|
|
1206
|
+
role: 'member',
|
|
1207
|
+
})
|
|
1208
|
+
addTeamMember(db, {
|
|
1209
|
+
teamId: team.id,
|
|
1210
|
+
userId: anotherMember.id,
|
|
1211
|
+
role: 'member',
|
|
1212
|
+
addedBy: owner.id,
|
|
1213
|
+
})
|
|
1214
|
+
// teamMember already removed, but let's test with a fresh member
|
|
1215
|
+
const freshMember = await createAndLoginUser(db, app, {
|
|
1216
|
+
username: 'fresh',
|
|
1217
|
+
role: 'member',
|
|
1218
|
+
})
|
|
1219
|
+
addTeamMember(db, {
|
|
1220
|
+
teamId: team.id,
|
|
1221
|
+
userId: freshMember.id,
|
|
1222
|
+
role: 'member',
|
|
1223
|
+
addedBy: owner.id,
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
const res2 = await app.request(
|
|
1227
|
+
`/web/teams/${team.id}/members/${anotherMember.id}`,
|
|
1228
|
+
{
|
|
1229
|
+
method: 'DELETE',
|
|
1230
|
+
headers: authHeader(freshMember.accessToken),
|
|
1231
|
+
},
|
|
1232
|
+
)
|
|
1233
|
+
expect(res2.status).toBe(403)
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
test('system admin → 200', async () => {
|
|
1237
|
+
const res = await app.request(
|
|
1238
|
+
`/web/teams/${team.id}/members/${teamMember.id}`,
|
|
1239
|
+
{
|
|
1240
|
+
method: 'DELETE',
|
|
1241
|
+
headers: authHeader(admin.accessToken),
|
|
1242
|
+
},
|
|
1243
|
+
)
|
|
1244
|
+
expect(res.status).toBe(200)
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
test('remove last owner → 400', async () => {
|
|
1248
|
+
// owner is the only owner; try to remove them
|
|
1249
|
+
const res = await app.request(`/web/teams/${team.id}/members/${owner.id}`, {
|
|
1250
|
+
method: 'DELETE',
|
|
1251
|
+
headers: authHeader(owner.accessToken),
|
|
1252
|
+
})
|
|
1253
|
+
expect(res.status).toBe(400)
|
|
1254
|
+
})
|
|
1255
|
+
})
|
|
1256
|
+
|
|
1257
|
+
// ===========================================================================
|
|
1258
|
+
// Team environment ownership
|
|
1259
|
+
// ===========================================================================
|
|
1260
|
+
|
|
1261
|
+
describe('POST /v1/environments/bridge — register with team', () => {
|
|
1262
|
+
let app: Hono
|
|
1263
|
+
let db: Database
|
|
1264
|
+
let admin: TestUser
|
|
1265
|
+
let member: TestUser
|
|
1266
|
+
let outsider: TestUser
|
|
1267
|
+
let team: { id: string }
|
|
1268
|
+
|
|
1269
|
+
beforeEach(async () => {
|
|
1270
|
+
db = freshDb()
|
|
1271
|
+
app = createApp()
|
|
1272
|
+
admin = await setupAdmin(app)
|
|
1273
|
+
member = await createAndLoginUser(db, app, {
|
|
1274
|
+
username: 'member1',
|
|
1275
|
+
role: 'member',
|
|
1276
|
+
})
|
|
1277
|
+
outsider = await createAndLoginUser(db, app, {
|
|
1278
|
+
username: 'outsider',
|
|
1279
|
+
role: 'member',
|
|
1280
|
+
})
|
|
1281
|
+
team = createTeamDirectly(db, {
|
|
1282
|
+
name: 'Env Team',
|
|
1283
|
+
slug: 'env-team',
|
|
1284
|
+
createdBy: member.id,
|
|
1285
|
+
})
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
test('with X-Team-Id header → environment belongs to that team', async () => {
|
|
1289
|
+
const res = await app.request('/v1/environments/bridge', {
|
|
1290
|
+
method: 'POST',
|
|
1291
|
+
headers: {
|
|
1292
|
+
...authHeader(member.accessToken),
|
|
1293
|
+
'X-Team-Id': team.id,
|
|
1294
|
+
'Content-Type': 'application/json',
|
|
1295
|
+
},
|
|
1296
|
+
body: JSON.stringify({
|
|
1297
|
+
secret: 'test-secret',
|
|
1298
|
+
machineName: 'dev-machine',
|
|
1299
|
+
directory: '/home/user/project',
|
|
1300
|
+
}),
|
|
1301
|
+
})
|
|
1302
|
+
// This endpoint may be in a different route; accept 200/201
|
|
1303
|
+
expect([200, 201]).toContain(res.status)
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
test('without X-Team-Id → belongs to user personal space', async () => {
|
|
1307
|
+
const res = await app.request('/v1/environments/bridge', {
|
|
1308
|
+
method: 'POST',
|
|
1309
|
+
headers: {
|
|
1310
|
+
...authHeader(member.accessToken),
|
|
1311
|
+
'Content-Type': 'application/json',
|
|
1312
|
+
},
|
|
1313
|
+
body: JSON.stringify({
|
|
1314
|
+
secret: 'test-secret',
|
|
1315
|
+
machineName: 'dev-machine',
|
|
1316
|
+
directory: '/home/user/project',
|
|
1317
|
+
}),
|
|
1318
|
+
})
|
|
1319
|
+
expect([200, 201]).toContain(res.status)
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
test('invalid team_id → 400', async () => {
|
|
1323
|
+
const res = await app.request('/v1/environments/bridge', {
|
|
1324
|
+
method: 'POST',
|
|
1325
|
+
headers: {
|
|
1326
|
+
...authHeader(member.accessToken),
|
|
1327
|
+
'X-Team-Id': 'team_nonexistent',
|
|
1328
|
+
'Content-Type': 'application/json',
|
|
1329
|
+
},
|
|
1330
|
+
body: JSON.stringify({
|
|
1331
|
+
secret: 'test-secret',
|
|
1332
|
+
machineName: 'dev-machine',
|
|
1333
|
+
}),
|
|
1334
|
+
})
|
|
1335
|
+
expect(res.status).toBe(400)
|
|
1336
|
+
})
|
|
1337
|
+
|
|
1338
|
+
test('user not a team member → 403', async () => {
|
|
1339
|
+
const res = await app.request('/v1/environments/bridge', {
|
|
1340
|
+
method: 'POST',
|
|
1341
|
+
headers: {
|
|
1342
|
+
...authHeader(outsider.accessToken),
|
|
1343
|
+
'X-Team-Id': team.id,
|
|
1344
|
+
'Content-Type': 'application/json',
|
|
1345
|
+
},
|
|
1346
|
+
body: JSON.stringify({
|
|
1347
|
+
secret: 'test-secret',
|
|
1348
|
+
machineName: 'dev-machine',
|
|
1349
|
+
}),
|
|
1350
|
+
})
|
|
1351
|
+
expect(res.status).toBe(403)
|
|
1352
|
+
})
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
describe('GET /web/teams/:id/environments', () => {
|
|
1356
|
+
let app: Hono
|
|
1357
|
+
let db: Database
|
|
1358
|
+
let admin: TestUser
|
|
1359
|
+
let member: TestUser
|
|
1360
|
+
let outsider: TestUser
|
|
1361
|
+
let team: { id: string }
|
|
1362
|
+
|
|
1363
|
+
beforeEach(async () => {
|
|
1364
|
+
db = freshDb()
|
|
1365
|
+
app = createApp()
|
|
1366
|
+
admin = await setupAdmin(app)
|
|
1367
|
+
member = await createAndLoginUser(db, app, {
|
|
1368
|
+
username: 'member1',
|
|
1369
|
+
role: 'member',
|
|
1370
|
+
})
|
|
1371
|
+
outsider = await createAndLoginUser(db, app, {
|
|
1372
|
+
username: 'outsider',
|
|
1373
|
+
role: 'member',
|
|
1374
|
+
})
|
|
1375
|
+
team = createTeamDirectly(db, {
|
|
1376
|
+
name: 'Env Team',
|
|
1377
|
+
slug: 'env-team',
|
|
1378
|
+
createdBy: member.id,
|
|
1379
|
+
})
|
|
1380
|
+
|
|
1381
|
+
// Add an environment to the team
|
|
1382
|
+
const envId = `env_${randomUUID().replace(/-/g, '')}`
|
|
1383
|
+
try {
|
|
1384
|
+
db.query(
|
|
1385
|
+
`INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`,
|
|
1386
|
+
).run({ $teamId: team.id, $envId: envId })
|
|
1387
|
+
} catch {
|
|
1388
|
+
// Table may not exist
|
|
1389
|
+
}
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
test('team member → 200 + environment list', async () => {
|
|
1393
|
+
const res = await app.request(`/web/teams/${team.id}/environments`, {
|
|
1394
|
+
headers: authHeader(member.accessToken),
|
|
1395
|
+
})
|
|
1396
|
+
expect(res.status).toBe(200)
|
|
1397
|
+
const body = (await res.json()) as Array<Record<string, unknown>>
|
|
1398
|
+
expect(body.length).toBeGreaterThanOrEqual(1)
|
|
1399
|
+
})
|
|
1400
|
+
|
|
1401
|
+
test('non-team member → 403', async () => {
|
|
1402
|
+
const res = await app.request(`/web/teams/${team.id}/environments`, {
|
|
1403
|
+
headers: authHeader(outsider.accessToken),
|
|
1404
|
+
})
|
|
1405
|
+
expect(res.status).toBe(403)
|
|
1406
|
+
})
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1409
|
+
describe('POST /web/environments/:id/transfer', () => {
|
|
1410
|
+
let app: Hono
|
|
1411
|
+
let db: Database
|
|
1412
|
+
let admin: TestUser
|
|
1413
|
+
let owner: TestUser
|
|
1414
|
+
let teamAdmin: TestUser
|
|
1415
|
+
let teamMember: TestUser
|
|
1416
|
+
let sourceTeam: { id: string }
|
|
1417
|
+
let targetTeam: { id: string }
|
|
1418
|
+
let envId: string
|
|
1419
|
+
|
|
1420
|
+
beforeEach(async () => {
|
|
1421
|
+
db = freshDb()
|
|
1422
|
+
app = createApp()
|
|
1423
|
+
admin = await setupAdmin(app)
|
|
1424
|
+
owner = await createAndLoginUser(db, app, {
|
|
1425
|
+
username: 'owner1',
|
|
1426
|
+
role: 'member',
|
|
1427
|
+
})
|
|
1428
|
+
teamAdmin = await createAndLoginUser(db, app, {
|
|
1429
|
+
username: 'tadmin',
|
|
1430
|
+
role: 'member',
|
|
1431
|
+
})
|
|
1432
|
+
teamMember = await createAndLoginUser(db, app, {
|
|
1433
|
+
username: 'tmember',
|
|
1434
|
+
role: 'member',
|
|
1435
|
+
})
|
|
1436
|
+
sourceTeam = createTeamDirectly(db, {
|
|
1437
|
+
name: 'Source',
|
|
1438
|
+
slug: 'source',
|
|
1439
|
+
createdBy: owner.id,
|
|
1440
|
+
})
|
|
1441
|
+
targetTeam = createTeamDirectly(db, {
|
|
1442
|
+
name: 'Target',
|
|
1443
|
+
slug: 'target',
|
|
1444
|
+
createdBy: owner.id,
|
|
1445
|
+
})
|
|
1446
|
+
addTeamMember(db, {
|
|
1447
|
+
teamId: sourceTeam.id,
|
|
1448
|
+
userId: teamAdmin.id,
|
|
1449
|
+
role: 'admin',
|
|
1450
|
+
addedBy: owner.id,
|
|
1451
|
+
})
|
|
1452
|
+
addTeamMember(db, {
|
|
1453
|
+
teamId: sourceTeam.id,
|
|
1454
|
+
userId: teamMember.id,
|
|
1455
|
+
role: 'member',
|
|
1456
|
+
addedBy: owner.id,
|
|
1457
|
+
})
|
|
1458
|
+
|
|
1459
|
+
envId = `env_${randomUUID().replace(/-/g, '')}`
|
|
1460
|
+
try {
|
|
1461
|
+
db.query(
|
|
1462
|
+
`INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`,
|
|
1463
|
+
).run({ $teamId: sourceTeam.id, $envId: envId })
|
|
1464
|
+
} catch {
|
|
1465
|
+
// Table may not exist
|
|
1466
|
+
}
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
test('team owner → 200', async () => {
|
|
1470
|
+
const res = await app.request(`/web/environments/${envId}/transfer`, {
|
|
1471
|
+
method: 'POST',
|
|
1472
|
+
headers: {
|
|
1473
|
+
...authHeader(owner.accessToken),
|
|
1474
|
+
'Content-Type': 'application/json',
|
|
1475
|
+
},
|
|
1476
|
+
body: JSON.stringify({ toTeamId: targetTeam.id }),
|
|
1477
|
+
})
|
|
1478
|
+
expect(res.status).toBe(200)
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
test('team admin → 200', async () => {
|
|
1482
|
+
const res = await app.request(`/web/environments/${envId}/transfer`, {
|
|
1483
|
+
method: 'POST',
|
|
1484
|
+
headers: {
|
|
1485
|
+
...authHeader(teamAdmin.accessToken),
|
|
1486
|
+
'Content-Type': 'application/json',
|
|
1487
|
+
},
|
|
1488
|
+
body: JSON.stringify({ toTeamId: targetTeam.id }),
|
|
1489
|
+
})
|
|
1490
|
+
expect(res.status).toBe(200)
|
|
1491
|
+
})
|
|
1492
|
+
|
|
1493
|
+
test('team member → 403', async () => {
|
|
1494
|
+
const res = await app.request(`/web/environments/${envId}/transfer`, {
|
|
1495
|
+
method: 'POST',
|
|
1496
|
+
headers: {
|
|
1497
|
+
...authHeader(teamMember.accessToken),
|
|
1498
|
+
'Content-Type': 'application/json',
|
|
1499
|
+
},
|
|
1500
|
+
body: JSON.stringify({ toTeamId: targetTeam.id }),
|
|
1501
|
+
})
|
|
1502
|
+
expect(res.status).toBe(403)
|
|
1503
|
+
})
|
|
1504
|
+
|
|
1505
|
+
test('system admin → 200', async () => {
|
|
1506
|
+
const res = await app.request(`/web/environments/${envId}/transfer`, {
|
|
1507
|
+
method: 'POST',
|
|
1508
|
+
headers: {
|
|
1509
|
+
...authHeader(admin.accessToken),
|
|
1510
|
+
'Content-Type': 'application/json',
|
|
1511
|
+
},
|
|
1512
|
+
body: JSON.stringify({ toTeamId: targetTeam.id }),
|
|
1513
|
+
})
|
|
1514
|
+
expect(res.status).toBe(200)
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
test('transfer to non-existent team → 404', async () => {
|
|
1518
|
+
const res = await app.request(`/web/environments/${envId}/transfer`, {
|
|
1519
|
+
method: 'POST',
|
|
1520
|
+
headers: {
|
|
1521
|
+
...authHeader(owner.accessToken),
|
|
1522
|
+
'Content-Type': 'application/json',
|
|
1523
|
+
},
|
|
1524
|
+
body: JSON.stringify({ toTeamId: 'team_nonexistent' }),
|
|
1525
|
+
})
|
|
1526
|
+
expect(res.status).toBe(404)
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
test('after transfer → source team cannot see the environment', async () => {
|
|
1530
|
+
await app.request(`/web/environments/${envId}/transfer`, {
|
|
1531
|
+
method: 'POST',
|
|
1532
|
+
headers: {
|
|
1533
|
+
...authHeader(owner.accessToken),
|
|
1534
|
+
'Content-Type': 'application/json',
|
|
1535
|
+
},
|
|
1536
|
+
body: JSON.stringify({ toTeamId: targetTeam.id }),
|
|
1537
|
+
})
|
|
1538
|
+
const res = await app.request(`/web/teams/${sourceTeam.id}/environments`, {
|
|
1539
|
+
headers: authHeader(owner.accessToken),
|
|
1540
|
+
})
|
|
1541
|
+
if (res.status === 200) {
|
|
1542
|
+
const body = (await res.json()) as Array<Record<string, unknown>>
|
|
1543
|
+
const found = body.find(e => (e.id || e.environment_id) === envId)
|
|
1544
|
+
expect(found).toBeUndefined()
|
|
1545
|
+
}
|
|
1546
|
+
})
|
|
1547
|
+
|
|
1548
|
+
test('after transfer → target team can see the environment', async () => {
|
|
1549
|
+
await app.request(`/web/environments/${envId}/transfer`, {
|
|
1550
|
+
method: 'POST',
|
|
1551
|
+
headers: {
|
|
1552
|
+
...authHeader(owner.accessToken),
|
|
1553
|
+
'Content-Type': 'application/json',
|
|
1554
|
+
},
|
|
1555
|
+
body: JSON.stringify({ toTeamId: targetTeam.id }),
|
|
1556
|
+
})
|
|
1557
|
+
const res = await app.request(`/web/teams/${targetTeam.id}/environments`, {
|
|
1558
|
+
headers: authHeader(owner.accessToken),
|
|
1559
|
+
})
|
|
1560
|
+
if (res.status === 200) {
|
|
1561
|
+
const body = (await res.json()) as Array<Record<string, unknown>>
|
|
1562
|
+
const found = body.find(e => (e.id || e.environment_id) === envId)
|
|
1563
|
+
expect(found).toBeDefined()
|
|
1564
|
+
}
|
|
1565
|
+
})
|
|
1566
|
+
|
|
1567
|
+
test('after transfer → historical sessions under the environment transfer too', async () => {
|
|
1568
|
+
// This is a semantic test — the implementation should ensure session_owners
|
|
1569
|
+
// records are updated to reflect the new team ownership.
|
|
1570
|
+
// We verify by checking that sessions in the transferred environment
|
|
1571
|
+
// are visible to members of the target team.
|
|
1572
|
+
const res = await app.request(`/web/environments/${envId}/transfer`, {
|
|
1573
|
+
method: 'POST',
|
|
1574
|
+
headers: {
|
|
1575
|
+
...authHeader(owner.accessToken),
|
|
1576
|
+
'Content-Type': 'application/json',
|
|
1577
|
+
},
|
|
1578
|
+
body: JSON.stringify({ toTeamId: targetTeam.id }),
|
|
1579
|
+
})
|
|
1580
|
+
expect([200, 201]).toContain(res.status)
|
|
1581
|
+
// Detailed session visibility is tested in session-visibility.test.ts
|
|
1582
|
+
})
|
|
1583
|
+
})
|
|
1584
|
+
|
|
1585
|
+
// ===========================================================================
|
|
1586
|
+
// Team invitations (Phase 2)
|
|
1587
|
+
// ===========================================================================
|
|
1588
|
+
|
|
1589
|
+
describe('POST /web/teams/:id/invitations', () => {
|
|
1590
|
+
let app: Hono
|
|
1591
|
+
let db: Database
|
|
1592
|
+
let admin: TestUser
|
|
1593
|
+
let owner: TestUser
|
|
1594
|
+
let teamAdmin: TestUser
|
|
1595
|
+
let teamMember: TestUser
|
|
1596
|
+
let team: { id: string }
|
|
1597
|
+
|
|
1598
|
+
beforeEach(async () => {
|
|
1599
|
+
db = freshDb()
|
|
1600
|
+
app = createApp()
|
|
1601
|
+
admin = await setupAdmin(app)
|
|
1602
|
+
owner = await createAndLoginUser(db, app, {
|
|
1603
|
+
username: 'owner1',
|
|
1604
|
+
role: 'member',
|
|
1605
|
+
})
|
|
1606
|
+
teamAdmin = await createAndLoginUser(db, app, {
|
|
1607
|
+
username: 'tadmin',
|
|
1608
|
+
role: 'member',
|
|
1609
|
+
})
|
|
1610
|
+
teamMember = await createAndLoginUser(db, app, {
|
|
1611
|
+
username: 'tmember',
|
|
1612
|
+
role: 'member',
|
|
1613
|
+
})
|
|
1614
|
+
team = createTeamDirectly(db, {
|
|
1615
|
+
name: 'Invite Team',
|
|
1616
|
+
slug: 'invite-team',
|
|
1617
|
+
createdBy: owner.id,
|
|
1618
|
+
})
|
|
1619
|
+
addTeamMember(db, {
|
|
1620
|
+
teamId: team.id,
|
|
1621
|
+
userId: teamAdmin.id,
|
|
1622
|
+
role: 'admin',
|
|
1623
|
+
addedBy: owner.id,
|
|
1624
|
+
})
|
|
1625
|
+
addTeamMember(db, {
|
|
1626
|
+
teamId: team.id,
|
|
1627
|
+
userId: teamMember.id,
|
|
1628
|
+
role: 'member',
|
|
1629
|
+
addedBy: owner.id,
|
|
1630
|
+
})
|
|
1631
|
+
})
|
|
1632
|
+
|
|
1633
|
+
test('team owner/admin → 201 + returns invitation token', async () => {
|
|
1634
|
+
const res = await app.request(`/web/teams/${team.id}/invitations`, {
|
|
1635
|
+
method: 'POST',
|
|
1636
|
+
headers: {
|
|
1637
|
+
...authHeader(owner.accessToken),
|
|
1638
|
+
'Content-Type': 'application/json',
|
|
1639
|
+
},
|
|
1640
|
+
body: JSON.stringify({ role: 'member' }),
|
|
1641
|
+
})
|
|
1642
|
+
expect(res.status).toBe(201)
|
|
1643
|
+
const body = (await res.json()) as Record<string, unknown>
|
|
1644
|
+
expect(body.token || body.inviteToken || body.invitationToken).toBeDefined()
|
|
1645
|
+
})
|
|
1646
|
+
|
|
1647
|
+
test('team member → 403', async () => {
|
|
1648
|
+
const res = await app.request(`/web/teams/${team.id}/invitations`, {
|
|
1649
|
+
method: 'POST',
|
|
1650
|
+
headers: {
|
|
1651
|
+
...authHeader(teamMember.accessToken),
|
|
1652
|
+
'Content-Type': 'application/json',
|
|
1653
|
+
},
|
|
1654
|
+
body: JSON.stringify({ role: 'member' }),
|
|
1655
|
+
})
|
|
1656
|
+
expect(res.status).toBe(403)
|
|
1657
|
+
})
|
|
1658
|
+
|
|
1659
|
+
test('system admin → 201', async () => {
|
|
1660
|
+
const res = await app.request(`/web/teams/${team.id}/invitations`, {
|
|
1661
|
+
method: 'POST',
|
|
1662
|
+
headers: {
|
|
1663
|
+
...authHeader(admin.accessToken),
|
|
1664
|
+
'Content-Type': 'application/json',
|
|
1665
|
+
},
|
|
1666
|
+
body: JSON.stringify({ role: 'member' }),
|
|
1667
|
+
})
|
|
1668
|
+
expect(res.status).toBe(201)
|
|
1669
|
+
})
|
|
1670
|
+
|
|
1671
|
+
test('specify role, expiry, and max_uses', async () => {
|
|
1672
|
+
const res = await app.request(`/web/teams/${team.id}/invitations`, {
|
|
1673
|
+
method: 'POST',
|
|
1674
|
+
headers: {
|
|
1675
|
+
...authHeader(owner.accessToken),
|
|
1676
|
+
'Content-Type': 'application/json',
|
|
1677
|
+
},
|
|
1678
|
+
body: JSON.stringify({
|
|
1679
|
+
role: 'admin',
|
|
1680
|
+
expiresIn: 86400,
|
|
1681
|
+
maxUses: 5,
|
|
1682
|
+
}),
|
|
1683
|
+
})
|
|
1684
|
+
expect(res.status).toBe(201)
|
|
1685
|
+
const body = (await res.json()) as Record<string, unknown>
|
|
1686
|
+
expect(body.token || body.inviteToken).toBeDefined()
|
|
1687
|
+
|
|
1688
|
+
// Verify invitation was stored with correct params
|
|
1689
|
+
if (body.token || body.inviteToken) {
|
|
1690
|
+
const token = (body.token || body.inviteToken) as string
|
|
1691
|
+
const tokenHash = createHash('sha256').update(token).digest('hex')
|
|
1692
|
+
const row = db
|
|
1693
|
+
.query(
|
|
1694
|
+
'SELECT role, max_uses, team_id FROM invitations WHERE token_hash = $hash',
|
|
1695
|
+
)
|
|
1696
|
+
.get({ $hash: tokenHash }) as {
|
|
1697
|
+
role: string
|
|
1698
|
+
max_uses: number
|
|
1699
|
+
team_id: string
|
|
1700
|
+
} | null
|
|
1701
|
+
expect(row?.role).toBe('admin')
|
|
1702
|
+
expect(row?.max_uses).toBe(5)
|
|
1703
|
+
expect(row?.team_id).toBe(team.id)
|
|
1704
|
+
}
|
|
1705
|
+
})
|
|
1706
|
+
})
|
|
1707
|
+
|
|
1708
|
+
describe('POST /web/auth/join (Phase 2 team association)', () => {
|
|
1709
|
+
let app: Hono
|
|
1710
|
+
let db: Database
|
|
1711
|
+
let admin: TestUser
|
|
1712
|
+
let owner: TestUser
|
|
1713
|
+
let team: { id: string }
|
|
1714
|
+
let inviteToken: string
|
|
1715
|
+
|
|
1716
|
+
beforeEach(async () => {
|
|
1717
|
+
db = freshDb()
|
|
1718
|
+
app = createApp()
|
|
1719
|
+
admin = await setupAdmin(app)
|
|
1720
|
+
owner = await createAndLoginUser(db, app, {
|
|
1721
|
+
username: 'owner1',
|
|
1722
|
+
role: 'member',
|
|
1723
|
+
})
|
|
1724
|
+
team = createTeamDirectly(db, {
|
|
1725
|
+
name: 'Join Team',
|
|
1726
|
+
slug: 'join-team',
|
|
1727
|
+
createdBy: owner.id,
|
|
1728
|
+
})
|
|
1729
|
+
|
|
1730
|
+
// Create invitation linked to team
|
|
1731
|
+
inviteToken = `inv_${randomUUID().replace(/-/g, '')}`
|
|
1732
|
+
const tokenHash = createHash('sha256').update(inviteToken).digest('hex')
|
|
1733
|
+
const futureExpiry = new Date(Date.now() + 86400_000).toISOString()
|
|
1734
|
+
const now = new Date().toISOString()
|
|
1735
|
+
try {
|
|
1736
|
+
db.query(
|
|
1737
|
+
`INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by)
|
|
1738
|
+
VALUES ($hash, $teamId, 'member', $exp, 5, 0, $createdBy)`,
|
|
1739
|
+
).run({
|
|
1740
|
+
$hash: tokenHash,
|
|
1741
|
+
$teamId: team.id,
|
|
1742
|
+
$exp: futureExpiry,
|
|
1743
|
+
$createdBy: owner.id,
|
|
1744
|
+
})
|
|
1745
|
+
} catch {
|
|
1746
|
+
// Invitations table may not have team_id column yet
|
|
1747
|
+
}
|
|
1748
|
+
})
|
|
1749
|
+
|
|
1750
|
+
test('valid invitation → creates user + joins team with correct role', async () => {
|
|
1751
|
+
const res = await app.request('/web/auth/join', {
|
|
1752
|
+
method: 'POST',
|
|
1753
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1754
|
+
body: JSON.stringify({
|
|
1755
|
+
inviteToken,
|
|
1756
|
+
username: 'joiner',
|
|
1757
|
+
password: 'joiner-password-123',
|
|
1758
|
+
}),
|
|
1759
|
+
})
|
|
1760
|
+
expect(res.status).toBe(200)
|
|
1761
|
+
const body = (await res.json()) as Record<string, unknown>
|
|
1762
|
+
expect(body.accessToken || body.access_token).toBeDefined()
|
|
1763
|
+
|
|
1764
|
+
// Verify user was added to team
|
|
1765
|
+
const userRow = db
|
|
1766
|
+
.query("SELECT id FROM users WHERE username = 'joiner'")
|
|
1767
|
+
.get() as { id: string } | null
|
|
1768
|
+
if (userRow) {
|
|
1769
|
+
const membership = db
|
|
1770
|
+
.query(
|
|
1771
|
+
'SELECT role FROM team_members WHERE team_id = $teamId AND user_id = $userId',
|
|
1772
|
+
)
|
|
1773
|
+
.get({ $teamId: team.id, $userId: userRow.id }) as {
|
|
1774
|
+
role: string
|
|
1775
|
+
} | null
|
|
1776
|
+
expect(membership?.role).toBe('member')
|
|
1777
|
+
}
|
|
1778
|
+
})
|
|
1779
|
+
|
|
1780
|
+
test('expired invitation → 400', async () => {
|
|
1781
|
+
const expiredToken = `inv_expired_${randomUUID().replace(/-/g, '')}`
|
|
1782
|
+
const tokenHash = createHash('sha256').update(expiredToken).digest('hex')
|
|
1783
|
+
const pastExpiry = new Date(Date.now() - 86400_000).toISOString()
|
|
1784
|
+
try {
|
|
1785
|
+
db.query(
|
|
1786
|
+
`INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by)
|
|
1787
|
+
VALUES ($hash, $teamId, 'member', $exp, 1, 0, $createdBy)`,
|
|
1788
|
+
).run({
|
|
1789
|
+
$hash: tokenHash,
|
|
1790
|
+
$teamId: team.id,
|
|
1791
|
+
$exp: pastExpiry,
|
|
1792
|
+
$createdBy: owner.id,
|
|
1793
|
+
})
|
|
1794
|
+
} catch {
|
|
1795
|
+
// Table may not have team_id
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const res = await app.request('/web/auth/join', {
|
|
1799
|
+
method: 'POST',
|
|
1800
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1801
|
+
body: JSON.stringify({
|
|
1802
|
+
inviteToken: expiredToken,
|
|
1803
|
+
username: 'expiredjoiner',
|
|
1804
|
+
password: 'expired-password-123',
|
|
1805
|
+
}),
|
|
1806
|
+
})
|
|
1807
|
+
expect(res.status).toBe(400)
|
|
1808
|
+
})
|
|
1809
|
+
|
|
1810
|
+
test('max_uses reached → 400', async () => {
|
|
1811
|
+
const usedToken = `inv_used_${randomUUID().replace(/-/g, '')}`
|
|
1812
|
+
const tokenHash = createHash('sha256').update(usedToken).digest('hex')
|
|
1813
|
+
const futureExpiry = new Date(Date.now() + 86400_000).toISOString()
|
|
1814
|
+
const now = new Date().toISOString()
|
|
1815
|
+
try {
|
|
1816
|
+
db.query(
|
|
1817
|
+
`INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by, consumed_at)
|
|
1818
|
+
VALUES ($hash, $teamId, 'member', $exp, 1, 1, $createdBy, $now)`,
|
|
1819
|
+
).run({
|
|
1820
|
+
$hash: tokenHash,
|
|
1821
|
+
$teamId: team.id,
|
|
1822
|
+
$exp: futureExpiry,
|
|
1823
|
+
$createdBy: owner.id,
|
|
1824
|
+
$now: now,
|
|
1825
|
+
})
|
|
1826
|
+
} catch {
|
|
1827
|
+
// Table schema may differ
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const res = await app.request('/web/auth/join', {
|
|
1831
|
+
method: 'POST',
|
|
1832
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1833
|
+
body: JSON.stringify({
|
|
1834
|
+
inviteToken: usedToken,
|
|
1835
|
+
username: 'usedjoiner',
|
|
1836
|
+
password: 'used-password-123',
|
|
1837
|
+
}),
|
|
1838
|
+
})
|
|
1839
|
+
expect(res.status).toBe(400)
|
|
1840
|
+
})
|
|
1841
|
+
|
|
1842
|
+
test('invalid invitation → 400', async () => {
|
|
1843
|
+
const res = await app.request('/web/auth/join', {
|
|
1844
|
+
method: 'POST',
|
|
1845
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1846
|
+
body: JSON.stringify({
|
|
1847
|
+
inviteToken: 'inv_totally_invalid',
|
|
1848
|
+
username: 'invalidjoiner',
|
|
1849
|
+
password: 'invalid-password-123',
|
|
1850
|
+
}),
|
|
1851
|
+
})
|
|
1852
|
+
expect(res.status).toBe(400)
|
|
1853
|
+
})
|
|
1854
|
+
})
|
|
1855
|
+
|
|
1856
|
+
describe('DELETE /web/teams/:id/invitations/:token', () => {
|
|
1857
|
+
let app: Hono
|
|
1858
|
+
let db: Database
|
|
1859
|
+
let admin: TestUser
|
|
1860
|
+
let owner: TestUser
|
|
1861
|
+
let teamMember: TestUser
|
|
1862
|
+
let team: { id: string }
|
|
1863
|
+
let activeToken: string
|
|
1864
|
+
|
|
1865
|
+
beforeEach(async () => {
|
|
1866
|
+
db = freshDb()
|
|
1867
|
+
app = createApp()
|
|
1868
|
+
admin = await setupAdmin(app)
|
|
1869
|
+
owner = await createAndLoginUser(db, app, {
|
|
1870
|
+
username: 'owner1',
|
|
1871
|
+
role: 'member',
|
|
1872
|
+
})
|
|
1873
|
+
teamMember = await createAndLoginUser(db, app, {
|
|
1874
|
+
username: 'tmember',
|
|
1875
|
+
role: 'member',
|
|
1876
|
+
})
|
|
1877
|
+
team = createTeamDirectly(db, {
|
|
1878
|
+
name: 'Revoke Team',
|
|
1879
|
+
slug: 'revoke-team',
|
|
1880
|
+
createdBy: owner.id,
|
|
1881
|
+
})
|
|
1882
|
+
addTeamMember(db, {
|
|
1883
|
+
teamId: team.id,
|
|
1884
|
+
userId: teamMember.id,
|
|
1885
|
+
role: 'member',
|
|
1886
|
+
addedBy: owner.id,
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1889
|
+
// Create an active invitation
|
|
1890
|
+
activeToken = `inv_active_${randomUUID().replace(/-/g, '')}`
|
|
1891
|
+
const tokenHash = createHash('sha256').update(activeToken).digest('hex')
|
|
1892
|
+
const futureExpiry = new Date(Date.now() + 86400_000).toISOString()
|
|
1893
|
+
try {
|
|
1894
|
+
db.query(
|
|
1895
|
+
`INSERT INTO invitations (token_hash, team_id, role, expires_at, max_uses, uses, created_by)
|
|
1896
|
+
VALUES ($hash, $teamId, 'member', $exp, 5, 0, $createdBy)`,
|
|
1897
|
+
).run({
|
|
1898
|
+
$hash: tokenHash,
|
|
1899
|
+
$teamId: team.id,
|
|
1900
|
+
$exp: futureExpiry,
|
|
1901
|
+
$createdBy: owner.id,
|
|
1902
|
+
})
|
|
1903
|
+
} catch {
|
|
1904
|
+
// Table schema may differ
|
|
1905
|
+
}
|
|
1906
|
+
})
|
|
1907
|
+
|
|
1908
|
+
test('team owner/admin → 200', async () => {
|
|
1909
|
+
const res = await app.request(
|
|
1910
|
+
`/web/teams/${team.id}/invitations/${activeToken}`,
|
|
1911
|
+
{
|
|
1912
|
+
method: 'DELETE',
|
|
1913
|
+
headers: authHeader(owner.accessToken),
|
|
1914
|
+
},
|
|
1915
|
+
)
|
|
1916
|
+
expect(res.status).toBe(200)
|
|
1917
|
+
})
|
|
1918
|
+
|
|
1919
|
+
test('team member → 403', async () => {
|
|
1920
|
+
const res = await app.request(
|
|
1921
|
+
`/web/teams/${team.id}/invitations/${activeToken}`,
|
|
1922
|
+
{
|
|
1923
|
+
method: 'DELETE',
|
|
1924
|
+
headers: authHeader(teamMember.accessToken),
|
|
1925
|
+
},
|
|
1926
|
+
)
|
|
1927
|
+
expect(res.status).toBe(403)
|
|
1928
|
+
})
|
|
1929
|
+
|
|
1930
|
+
test('revoked invitation → /web/auth/join returns 400', async () => {
|
|
1931
|
+
// Revoke the invitation
|
|
1932
|
+
await app.request(`/web/teams/${team.id}/invitations/${activeToken}`, {
|
|
1933
|
+
method: 'DELETE',
|
|
1934
|
+
headers: authHeader(owner.accessToken),
|
|
1935
|
+
})
|
|
1936
|
+
|
|
1937
|
+
// Try to join with the revoked token
|
|
1938
|
+
const res = await app.request('/web/auth/join', {
|
|
1939
|
+
method: 'POST',
|
|
1940
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1941
|
+
body: JSON.stringify({
|
|
1942
|
+
inviteToken: activeToken,
|
|
1943
|
+
username: 'revokedjoiner',
|
|
1944
|
+
password: 'revoked-password-123',
|
|
1945
|
+
}),
|
|
1946
|
+
})
|
|
1947
|
+
expect(res.status).toBe(400)
|
|
1948
|
+
})
|
|
1949
|
+
})
|
|
1950
|
+
|
|
1951
|
+
// ===========================================================================
|
|
1952
|
+
// System admin global permissions
|
|
1953
|
+
// ===========================================================================
|
|
1954
|
+
|
|
1955
|
+
describe('system admin global permissions', () => {
|
|
1956
|
+
let app: Hono
|
|
1957
|
+
let db: Database
|
|
1958
|
+
let admin: TestUser
|
|
1959
|
+
let owner: TestUser
|
|
1960
|
+
let member: TestUser
|
|
1961
|
+
let team: { id: string }
|
|
1962
|
+
|
|
1963
|
+
beforeEach(async () => {
|
|
1964
|
+
db = freshDb()
|
|
1965
|
+
app = createApp()
|
|
1966
|
+
admin = await setupAdmin(app)
|
|
1967
|
+
owner = await createAndLoginUser(db, app, {
|
|
1968
|
+
username: 'owner1',
|
|
1969
|
+
role: 'member',
|
|
1970
|
+
})
|
|
1971
|
+
member = await createAndLoginUser(db, app, {
|
|
1972
|
+
username: 'member1',
|
|
1973
|
+
role: 'member',
|
|
1974
|
+
})
|
|
1975
|
+
team = createTeamDirectly(db, {
|
|
1976
|
+
name: 'Admin Test',
|
|
1977
|
+
slug: 'admin-test',
|
|
1978
|
+
createdBy: owner.id,
|
|
1979
|
+
})
|
|
1980
|
+
addTeamMember(db, {
|
|
1981
|
+
teamId: team.id,
|
|
1982
|
+
userId: member.id,
|
|
1983
|
+
role: 'member',
|
|
1984
|
+
addedBy: owner.id,
|
|
1985
|
+
})
|
|
1986
|
+
// admin is NOT in team_members — should still have access
|
|
1987
|
+
})
|
|
1988
|
+
|
|
1989
|
+
test('admin not in team_members but can manage all teams', async () => {
|
|
1990
|
+
// Verify admin is not in team_members
|
|
1991
|
+
const membership = db
|
|
1992
|
+
.query(
|
|
1993
|
+
'SELECT * FROM team_members WHERE team_id = $teamId AND user_id = $userId',
|
|
1994
|
+
)
|
|
1995
|
+
.get({ $teamId: team.id, $userId: admin.id })
|
|
1996
|
+
expect(membership).toBeNull()
|
|
1997
|
+
|
|
1998
|
+
// Admin can still GET team details
|
|
1999
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
2000
|
+
headers: authHeader(admin.accessToken),
|
|
2001
|
+
})
|
|
2002
|
+
expect(res.status).toBe(200)
|
|
2003
|
+
})
|
|
2004
|
+
|
|
2005
|
+
test('admin can view all team members, environments, sessions', async () => {
|
|
2006
|
+
// Members
|
|
2007
|
+
const membersRes = await app.request(`/web/teams/${team.id}/members`, {
|
|
2008
|
+
headers: authHeader(admin.accessToken),
|
|
2009
|
+
})
|
|
2010
|
+
expect(membersRes.status).toBe(200)
|
|
2011
|
+
|
|
2012
|
+
// Environments
|
|
2013
|
+
const envsRes = await app.request(`/web/teams/${team.id}/environments`, {
|
|
2014
|
+
headers: authHeader(admin.accessToken),
|
|
2015
|
+
})
|
|
2016
|
+
expect(envsRes.status).toBe(200)
|
|
2017
|
+
})
|
|
2018
|
+
|
|
2019
|
+
test('admin can force-add members to any team', async () => {
|
|
2020
|
+
const newMember = await createAndLoginUser(db, app, {
|
|
2021
|
+
username: 'forced',
|
|
2022
|
+
role: 'member',
|
|
2023
|
+
})
|
|
2024
|
+
const res = await app.request(`/web/teams/${team.id}/members`, {
|
|
2025
|
+
method: 'POST',
|
|
2026
|
+
headers: {
|
|
2027
|
+
...authHeader(admin.accessToken),
|
|
2028
|
+
'Content-Type': 'application/json',
|
|
2029
|
+
},
|
|
2030
|
+
body: JSON.stringify({ userId: newMember.id, role: 'member' }),
|
|
2031
|
+
})
|
|
2032
|
+
expect(res.status).toBe(201)
|
|
2033
|
+
})
|
|
2034
|
+
|
|
2035
|
+
test('admin can force-remove members from any team', async () => {
|
|
2036
|
+
const res = await app.request(
|
|
2037
|
+
`/web/teams/${team.id}/members/${member.id}`,
|
|
2038
|
+
{
|
|
2039
|
+
method: 'DELETE',
|
|
2040
|
+
headers: authHeader(admin.accessToken),
|
|
2041
|
+
},
|
|
2042
|
+
)
|
|
2043
|
+
expect(res.status).toBe(200)
|
|
2044
|
+
})
|
|
2045
|
+
|
|
2046
|
+
test('admin can delete any team', async () => {
|
|
2047
|
+
const res = await app.request(`/web/teams/${team.id}`, {
|
|
2048
|
+
method: 'DELETE',
|
|
2049
|
+
headers: authHeader(admin.accessToken),
|
|
2050
|
+
})
|
|
2051
|
+
expect(res.status).toBe(200)
|
|
2052
|
+
})
|
|
2053
|
+
|
|
2054
|
+
test('admin can transfer environments across teams', async () => {
|
|
2055
|
+
const otherTeam = createTeamDirectly(db, {
|
|
2056
|
+
name: 'Other',
|
|
2057
|
+
slug: 'other',
|
|
2058
|
+
createdBy: owner.id,
|
|
2059
|
+
})
|
|
2060
|
+
const envId = `env_${randomUUID().replace(/-/g, '')}`
|
|
2061
|
+
try {
|
|
2062
|
+
db.query(
|
|
2063
|
+
`INSERT INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)`,
|
|
2064
|
+
).run({ $teamId: team.id, $envId: envId })
|
|
2065
|
+
} catch {
|
|
2066
|
+
// Table may not exist
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
const res = await app.request(`/web/environments/${envId}/transfer`, {
|
|
2070
|
+
method: 'POST',
|
|
2071
|
+
headers: {
|
|
2072
|
+
...authHeader(admin.accessToken),
|
|
2073
|
+
'Content-Type': 'application/json',
|
|
2074
|
+
},
|
|
2075
|
+
body: JSON.stringify({ toTeamId: otherTeam.id }),
|
|
2076
|
+
})
|
|
2077
|
+
expect(res.status).toBe(200)
|
|
2078
|
+
})
|
|
2079
|
+
|
|
2080
|
+
test('admin can view all orphan sessions', async () => {
|
|
2081
|
+
// This is a visibility test — admin should see sessions with no owners
|
|
2082
|
+
// Tested in detail in session-visibility.test.ts
|
|
2083
|
+
// Here we just verify the admin can access the session list endpoint
|
|
2084
|
+
const res = await app.request('/web/teams', {
|
|
2085
|
+
headers: authHeader(admin.accessToken),
|
|
2086
|
+
})
|
|
2087
|
+
expect(res.status).toBe(200)
|
|
2088
|
+
})
|
|
2089
|
+
})
|