diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..b35a158 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,44 @@ +name: Integration Test + +on: + pull_request: + +jobs: + integration-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create external Docker network if not exists + run: | + docker network inspect topic_master_network >/dev/null 2>&1 || \ + docker network create topic_master_network + + - name: Start infra/test_setup stack + run: | + docker compose -f infra/test_setup/docker-compose.yml up -d + + - name: Wait for NSQ services to be healthy + run: | + for i in {1..30}; do + if docker ps | grep nsqlookupd && docker ps | grep nsqd; then + echo "NSQ services are up" && break + fi + echo "Waiting for NSQ services..." + sleep 2 + done + + - name: Start topic-master and test-script + run: | + docker compose -f infra/test_script/docker-compose.yml up --build --abort-on-container-exit --exit-code-from test-script + + - name: Cleanup + if: always() + run: | + docker compose -f infra/test_script/docker-compose.yml down -v || true + docker compose -f infra/test_setup/docker-compose.yml down -v || true + docker network rm topic_master_network || true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7c6bf1f..a8225ad 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ topic-master *.db __debug_bin* *.json -.vscode/ docs/public/* docs/resources/_gen/* bin/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a1f2a0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# Build stage +FROM golang:1.23-alpine AS builder +WORKDIR /app +COPY . . +RUN go mod download +RUN go build -o topic-master *.go + +# Run stage +FROM alpine:latest +WORKDIR /app +RUN mkdir -p /app/infra/test_data/ +COPY --from=builder /app/topic-master . +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh +ENTRYPOINT ["/app/entrypoint.sh"] +CMD [] \ No newline at end of file diff --git a/Makefile b/Makefile index e4f6fd1..fcc7f03 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ start-test-setup: - docker compose -f infra/test_setup/docker-compose.yml up + docker compose -f infra/test_setup/docker-compose.yml -f infra/test_setup/docker-compose.override.yml up start-test-detach: - docker compose -f infra/test_setup/docker-compose.yml up -d + docker compose -f infra/test_setup/docker-compose.yml -f infra/test_setup/docker-compose.override.yml up -d stop-test-setup: - docker compose -f infra/test_setup/docker-compose.yml down + docker compose -f infra/test_setup/docker-compose.yml -f infra/test_setup/docker-compose.override.yml down start-docs: hugo server -s docs -D --disableFastRender -p 1414 @@ -27,3 +27,9 @@ build-macos-arm64: GOOS=darwin GOARCH=arm64 go build -o bin/topic-master-darwin-arm64 *.go build-all: build-linux-amd64 build-linux-arm64 build-macos-amd64 build-macos-arm64 + +start-test-all: + docker compose -f infra/test_setup/docker-compose.yml -f infra/test_setup/docker-compose.override.yml -f infra/test_script/docker-compose.yml up --abort-on-container-exit + +start-test-script: + docker compose -f infra/test_script/docker-compose.yml up --abort-on-container-exit \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..b08a481 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/sh +rm -rf /app/infra/test_data/* +exec /app/topic-master "$@" \ No newline at end of file diff --git a/go.mod b/go.mod index b9e3e65..c287f9f 100644 --- a/go.mod +++ b/go.mod @@ -15,32 +15,17 @@ require ( github.com/tidwall/buntdb v1.3.2 github.com/vmihailenco/msgpack/v5 v5.4.1 go.uber.org/mock v0.5.2 + golang.org/x/term v0.32.0 ) require ( - github.com/cilium/ebpf v0.11.0 // indirect - github.com/cosiner/argv v0.1.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/derekparker/trie v0.0.0-20230829180723-39f4de51ef7d // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/go-delve/delve v1.25.0 // indirect - github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang/snappy v0.0.1 // indirect - github.com/google/go-dap v0.12.0 // indirect - github.com/hashicorp/golang-lru v1.0.2 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect github.com/tidwall/btree v1.4.2 // indirect github.com/tidwall/gjson v1.14.3 // indirect github.com/tidwall/grect v0.1.4 // indirect @@ -49,15 +34,9 @@ require ( github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - go.starlark.net v0.0.0-20231101134539-556fd59b42f6 // indirect - golang.org/x/arch v0.11.0 // indirect golang.org/x/crypto v0.33.0 // indirect - golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 // indirect golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/telemetry v0.0.0-20241106142447-58a1122356f5 // indirect - golang.org/x/term v0.32.0 // indirect golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e3f3b85..4e03ab4 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,7 @@ -github.com/cilium/ebpf v0.11.0 h1:V8gS/bTCCjX9uUnkUFUpPsksM8n1lXBAvHcpiFk1X2Y= -github.com/cilium/ebpf v0.11.0/go.mod h1:WE7CZAnqOL2RouJ4f1uyNhqr2P4CCvXFIqdRDUgWsVs= -github.com/cosiner/argv v0.1.0 h1:BVDiEL32lwHukgJKP87btEPenzrrHUjajs/8yzaqcXg= -github.com/cosiner/argv v0.1.0/go.mod h1:EusR6TucWKX+zFgtdUsKT2Cvg45K5rtpCcWz4hK06d8= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/derekparker/trie v0.0.0-20230829180723-39f4de51ef7d h1:hUWoLdw5kvo2xCsqlsIBMvWUc1QCSsCYD2J2+Fg6YoU= -github.com/derekparker/trie v0.0.0-20230829180723-39f4de51ef7d/go.mod h1:C7Es+DLenIpPc9J6IYw4jrK0h7S9bKj4DNl8+KxGEXU= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/go-delve/delve v1.25.0 h1:JN2S3iVptvayUS2w+d0UEPmijgkodW1AFM4I8UViHGE= -github.com/go-delve/delve v1.25.0/go.mod h1:kJk12wo6PqzWknTP6M+Pg3/CrNhFMZvNq1iHESKkhv8= -github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62 h1:IGtvsNyIuRjl04XAOFGACozgUD7A82UffYxZt4DWbvA= -github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62/go.mod h1:biJCRbqp51wS+I92HMqn5H8/A0PAhxn2vyOT+JqhiGI= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -28,38 +16,16 @@ github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-dap v0.12.0 h1:rVcjv3SyMIrpaOoTAdFDyHs99CwVOItIJGKLQFQhNeM= -github.com/google/go-dap v0.12.0/go.mod h1:tNjCASCm5cqePi/RVXXWEVqtnNLV1KTWtYOqu6rZNzc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= -github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/nsqio/go-nsq v1.1.0 h1:PQg+xxiUjA7V+TLdXw7nVrJ5Jbl3sN86EhGCQj4+FYE= github.com/nsqio/go-nsq v1.1.0/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= @@ -88,18 +54,12 @@ github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.starlark.net v0.0.0-20231101134539-556fd59b42f6 h1:+eC0F/k4aBLC4szgOcjd7bDTEnpxADJyWJE0yowgM3E= -go.starlark.net v0.0.0-20231101134539-556fd59b42f6/go.mod h1:LcLNIzVOMp4oV+uusnpk+VU+SzXaJakUuBjoCSWH5dM= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= -golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 h1:Jvc7gsqn21cJHCmAWx0LiimpP18LZmUxkT5Mp7EZ1mI= -golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -108,22 +68,13 @@ golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/telemetry v0.0.0-20241106142447-58a1122356f5 h1:TCDqnvbBsFapViksHcHySl/sW4+rTGNIAoJJesHRuMM= -golang.org/x/telemetry v0.0.0-20241106142447-58a1122356f5/go.mod h1:8nZWdGp9pq73ZI//QJyckMQab3yq7hoWi7SI0UIusVI= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= diff --git a/handler.go b/handler.go index dadb0c2..07dc28b 100644 --- a/handler.go +++ b/handler.go @@ -127,6 +127,11 @@ func (h Handler) routes(mux *http.ServeMux) { mux.HandleFunc("/api/user/update", rootMiddleware(handlerPkg.HandleGenericPost(h.updateUserUC.Handle))) mux.HandleFunc("/api/user/assign-to-group", rootMiddleware(handlerPkg.HandleGenericPost(h.assignUserToGroupUC.Handle))) mux.HandleFunc("/api/user/delete", rootMiddleware(handlerPkg.HandleGenericPost(h.deleteUserUC.Handle))) + mux.HandleFunc("/api/user/change-password", handlerPkg.HandleGenericPost(h.changePasswordUC.Handle)) + mux.HandleFunc("/api/user/reset-password", handlerPkg.HandleGetPost( + h.resetPasswordUC.HandleGet, + h.resetPasswordUC.HandlePost, + )) mux.HandleFunc("/api/change-password", handlerPkg.HandleGenericPost(h.changePasswordUC.Handle)) mux.HandleFunc("/api/sync-topics", handlerPkg.HandleGenericGet(h.syncTopicsUC.HandleQuery)) @@ -158,12 +163,16 @@ func (h Handler) routes(mux *http.ServeMux) { mux.HandleFunc("/api/topic/detail", sessionMiddleware(handlerPkg.HandleGenericGet(h.getTopicDetailUC.HandleQuery))) mux.HandleFunc("/api/topic/stats", sessionMiddleware(handlerPkg.HandleGenericGet(h.getTopicStatsUC.HandleQuery))) - mux.HandleFunc("/api/entity/update-description", sessionMiddleware(handlerPkg.HandleGenericPost(h.updateDescriptionUC.Save))) mux.HandleFunc("/api/entity/toggle-bookmark", authMiddleware(handlerPkg.HandleGenericPost(h.toggleBookmarkUC.Toggle))) // this middleware is action auth required actionAuthMiddleware := handlerPkg.InitActionAuthMiddleware(string(h.config.SecretKey), h.checkActionAuthUC) + mux.HandleFunc("/api/entity/update-description", sessionMiddleware(actionAuthMiddleware( + handlerPkg.HandleGenericPost(h.updateDescriptionUC.Save), + acl.Permission_Entity_Desc_Update.Name, + ))) + mux.HandleFunc("/api/topic/publish", sessionMiddleware(actionAuthMiddleware( handlerPkg.HandleGenericPost(h.getTopicDetailUC.HandlePublish), acl.Permission_Topic_Publish.Name, diff --git a/infra/test_script/Dockerfile b/infra/test_script/Dockerfile new file mode 100644 index 0000000..9c0a9f9 --- /dev/null +++ b/infra/test_script/Dockerfile @@ -0,0 +1,5 @@ +FROM golang:1.23-alpine +WORKDIR /app +COPY . . +RUN go mod download +CMD ["go", "test", "./..."] \ No newline at end of file diff --git a/infra/test_script/docker-compose.yml b/infra/test_script/docker-compose.yml new file mode 100644 index 0000000..87fddec --- /dev/null +++ b/infra/test_script/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' +services: + topic-master: + build: + context: ../../ + dockerfile: Dockerfile + image: topic-master:latest + container_name: topic-master + command: + - -data_path=/app/infra/test_data/ + - -port=4181 + - -nsqlookupd_http_address=http://nsqlookupd:4161 + environment: + - TOPIC_MASTER_ROOT_PASS=rootroot + ports: + - 4181:4181 + networks: + - topic_master_network + test-script: + build: + context: . + dockerfile: Dockerfile + image: test-script:latest + container_name: test-script + environment: + - TOPIC_MASTER_HOST=http://topic-master:4181 + depends_on: + - topic-master + networks: + - topic_master_network +networks: + topic_master_network: + external: true diff --git a/infra/test_script/go.mod b/infra/test_script/go.mod new file mode 100644 index 0000000..f3968e6 --- /dev/null +++ b/infra/test_script/go.mod @@ -0,0 +1,14 @@ +module github.com/jekiapp/topic-master/infra/test_script + +go 1.23.9 + +require ( + github.com/stretchr/testify v1.10.0 + nhooyr.io/websocket v1.8.17 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/infra/test_script/go.sum b/infra/test_script/go.sum new file mode 100644 index 0000000..1069708 --- /dev/null +++ b/infra/test_script/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/infra/test_script/helpers/user_helpers.go b/infra/test_script/helpers/user_helpers.go new file mode 100644 index 0000000..c47df89 --- /dev/null +++ b/infra/test_script/helpers/user_helpers.go @@ -0,0 +1,247 @@ +package helpers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Groups string `json:"groups"` + GroupDetails []GroupDetail `json:"group_details"` +} + +type GroupDetail struct { + GroupID string `json:"group_id"` + Role string `json:"role"` +} + +type TestGroup struct { + ID string + Name string + Description string + Members string +} + +// GetHost returns the test host, respecting the TOPIC_MASTER_HOST environment variable if set. +func GetHost() string { + host := os.Getenv("TOPIC_MASTER_HOST") + if host != "" { + return host + } + return "http://localhost:4181" +} + +func LoginAsRoot(t *testing.T, client *http.Client, host string) string { + loginPayload := map[string]string{ + "username": "root", + "password": "rootroot", + } + loginBody, _ := json.Marshal(loginPayload) + loginReq, _ := http.NewRequest("POST", host+"/api/login", bytes.NewReader(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + + loginResp, err := client.Do(loginReq) + require.NoError(t, err) + defer loginResp.Body.Close() + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + var accessToken string + for _, c := range loginResp.Cookies() { + if c.Name == "access_token" { + accessToken = c.Value + } + } + require.NotEmpty(t, accessToken, "access_token cookie should be set after login") + return accessToken +} + +func GetAllGroups(t *testing.T, client *http.Client, host, accessToken string) ([]TestGroup, error) { + groupListReq, _ := http.NewRequest("POST", host+"/api/group/list", bytes.NewReader([]byte(`{}`))) + groupListReq.Header.Set("Content-Type", "application/json") + groupListReq.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + groupListResp, err := client.Do(groupListReq) + require.NoError(t, err) + defer groupListResp.Body.Close() + body, _ := io.ReadAll(groupListResp.Body) + var groupList struct { + Data struct { + Groups []struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Members string `json:"members"` + } `json:"groups"` + } `json:"data"` + } + err = json.Unmarshal(body, &groupList) + require.NoError(t, err) + var result []TestGroup + for _, g := range groupList.Data.Groups { + result = append(result, TestGroup{ + ID: g.ID, + Name: g.Name, + Description: g.Description, + Members: g.Members, + }) + } + return result, nil +} + +func CreateGroup(t *testing.T, client *http.Client, host, accessToken, name, description string) TestGroup { + createReq := map[string]string{ + "name": name, + "description": description, + } + body, _ := json.Marshal(createReq) + req, _ := http.NewRequest("POST", host+"/api/group/create", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + if !assert.Equal(t, http.StatusOK, resp.StatusCode) { + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + } + groups, err := GetAllGroups(t, client, host, accessToken) + require.NoError(t, err) + for _, g := range groups { + if g.Name == name { + return g + } + } + t.Fatalf("group %s not found after creation", name) + return TestGroup{} +} + +func GetAllUsers(client *http.Client, accessToken string) ([]User, error) { + req, _ := http.NewRequest("POST", GetHost()+"/api/user/list", bytes.NewReader([]byte(`{}`))) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var userList struct { + Data struct { + Users []User `json:"users"` + } `json:"data"` + } + err = json.Unmarshal(body, &userList) + if err != nil { + fmt.Println(string(body)) + return nil, err + } + return userList.Data.Users, nil +} + +type GroupsReq struct { + GroupID string `json:"group_id"` + Role string `json:"role"` +} + +func CreateUser(client *http.Client, accessToken, username, name, password string, groups []GroupsReq) (User, error) { + createReq := map[string]interface{}{ + "username": username, + "name": name, + "password": password, + "groups": groups, + } + + body, _ := json.Marshal(createReq) + req, _ := http.NewRequest("POST", GetHost()+"/api/user/create", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + if err != nil { + return User{}, err + } + defer resp.Body.Close() + bodyresp, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return User{}, fmt.Errorf("failed to create user %s: %s, %s", username, resp.Status, string(bodyresp)) + } + users, err := GetAllUsers(client, accessToken) + if err != nil { + return User{}, err + } + for _, u := range users { + if u.Username == username { + return u, nil + } + } + return User{}, fmt.Errorf("user %s not found after creation", username) +} + +// DeleteGroup deletes a group by its ID using the API +func DeleteGroup( + t *testing.T, + client *http.Client, + accessToken string, + groupID string, +) { + deleteReq := map[string]string{ + "id": groupID, + } + body, _ := json.Marshal(deleteReq) + req, _ := http.NewRequest("POST", GetHost()+"/api/group/delete-group", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + if !assert.Equal(t, http.StatusOK, resp.StatusCode) { + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + } +} + +// DeleteUser deletes a user by its ID using the API +func DeleteUser( + t *testing.T, + client *http.Client, + accessToken string, + userID string, +) { + deleteReq := map[string]string{ + "user_id": userID, + } + body, _ := json.Marshal(deleteReq) + req, _ := http.NewRequest("POST", GetHost()+"/api/user/delete", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + if !assert.Equal(t, http.StatusOK, resp.StatusCode) { + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + } +} + +// LoginUser logs in a user and returns the response and cookies. +func LoginUser(t *testing.T, client *http.Client, username, password string) (*http.Response, []*http.Cookie) { + loginPayload := map[string]string{ + "username": username, + "password": password, + } + loginBody, _ := json.Marshal(loginPayload) + loginReq, _ := http.NewRequest("POST", GetHost()+"/api/login", bytes.NewReader(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginResp, err := client.Do(loginReq) + require.NoError(t, err) + return loginResp, loginResp.Cookies() +} diff --git a/infra/test_script/logged-in/logged_in_test.go b/infra/test_script/logged-in/logged_in_test.go new file mode 100644 index 0000000..f3abc5f --- /dev/null +++ b/infra/test_script/logged-in/logged_in_test.go @@ -0,0 +1,649 @@ +package loggedin + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + helpers "github.com/jekiapp/topic-master/infra/test_script/helpers" + "github.com/stretchr/testify/require" +) + +func TestLoggedIn(t *testing.T) { + client := &http.Client{} + rootToken := helpers.LoginAsRoot(t, client, helpers.GetHost()) + groupPayment := helpers.CreateGroup( + t, + client, + helpers.GetHost(), + rootToken, + "payment-team", + "Payment Team for logged in test", + ) + groupOrder := helpers.CreateGroup( + t, + client, + helpers.GetHost(), + rootToken, + "order-team", + "Order Team for logged in test", + ) + + aliceToken := UserSignup(t, "alice", rootToken, groupPayment) + bobToken := UserSignup(t, "bob", rootToken, groupOrder) + charlieToken := UserSignup(t, "charlie", rootToken, groupPayment) + + // alice can list all topics + t.Run("alice list topics", func(t *testing.T) { + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/topic/list-all-topics", nil) + req.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to GET /api/topic/list-all-topics: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + var result struct { + Data struct { + Topics []struct { + ID string `json:"id"` + Name string `json:"name"` + Bookmarked bool `json:"bookmarked"` + } `json:"topics"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(result.Data.Topics) < 3 { + t.Fatalf("expected at least 3 topics, got %d", len(result.Data.Topics)) + } + }) + + // alice bookmark 3 top topics -> validate bookmark + var bookmarkedTopicIDs []string + t.Run("alice bookmark topics", func(t *testing.T) { + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/topic/list-all-topics", nil) + req.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to GET /api/topic/list-all-topics: %v", err) + } + defer resp.Body.Close() + var result struct { + Data struct { + Topics []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"topics"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + t.Fatalf("failed to decode response: %v", err) + } + for i := 0; i < 3; i++ { + topic := result.Data.Topics[i] + bookmarkReq := map[string]interface{}{ + "entity_id": topic.ID, + "bookmark": true, + } + body, _ := json.Marshal(bookmarkReq) + bookmarkRequest, _ := http.NewRequest("POST", helpers.GetHost()+"/api/entity/toggle-bookmark", bytes.NewReader(body)) + bookmarkRequest.Header.Set("Content-Type", "application/json") + bookmarkRequest.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + bookmarkResp, err := client.Do(bookmarkRequest) + if err != nil { + t.Fatalf("failed to POST toggle-bookmark: %v", err) + } + defer bookmarkResp.Body.Close() + if bookmarkResp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(bookmarkResp.Body) + t.Fatalf("unexpected status code for bookmark: %d, body: %s", bookmarkResp.StatusCode, string(respBody)) + } + bookmarkedTopicIDs = append(bookmarkedTopicIDs, topic.ID) + } + + // Validate bookmarks using is_bookmarked=true + validateReq, _ := http.NewRequest("GET", helpers.GetHost()+"/api/topic/list-all-topics?is_bookmarked=true", nil) + validateReq.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + validateResp, err := client.Do(validateReq) + if err != nil { + t.Fatalf("failed to GET /api/topic/list-all-topics?is_bookmarked=true: %v", err) + } + defer validateResp.Body.Close() + var validateResult struct { + Data struct { + Topics []struct { + ID string `json:"id"` + } `json:"topics"` + } `json:"data"` + } + err = json.NewDecoder(validateResp.Body).Decode(&validateResult) + if err != nil { + t.Fatalf("failed to decode validate response: %v", err) + } + found := 0 + for _, topic := range validateResult.Data.Topics { + for _, id := range bookmarkedTopicIDs { + if topic.ID == id { + found++ + } + } + } + if found != 3 { + t.Fatalf("expected 3 bookmarked topics, got %d", found) + } + }) + + // alice can get topic detail topic[0] -> validate to be bookmarked + var cachedNsqdHosts string + var cachedTopicName string + t.Run("alice topic detail bookmarked", func(t *testing.T) { + if len(bookmarkedTopicIDs) == 0 { + t.Skip("no bookmarked topics from previous step") + } + topicID := bookmarkedTopicIDs[0] + detailReq, _ := http.NewRequest("GET", helpers.GetHost()+"/api/topic/detail?topic="+topicID, nil) + detailReq.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + detailResp, err := client.Do(detailReq) + if err != nil { + t.Fatalf("failed to GET /api/topic/detail: %v", err) + } + defer detailResp.Body.Close() + if detailResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(detailResp.Body) + t.Fatalf("unexpected status code: %d, body: %s", detailResp.StatusCode, string(body)) + } + var detail struct { + Data struct { + ID string `json:"id"` + Name string `json:"name"` + Bookmarked bool `json:"bookmarked"` + NsqdHosts []struct { + Address string `json:"address"` + } `json:"nsqd_hosts"` + } `json:"data"` + } + err = json.NewDecoder(detailResp.Body).Decode(&detail) + if err != nil { + t.Fatalf("failed to decode detail response: %v", err) + } + if !detail.Data.Bookmarked { + t.Fatalf("expected topic to be bookmarked, got false") + } + // Cache nsqd_hosts and topic name for later use + hosts := "" + for i, h := range detail.Data.NsqdHosts { + hosts += h.Address + if i < len(detail.Data.NsqdHosts)-1 { + hosts += "," + } + } + cachedNsqdHosts = hosts + cachedTopicName = detail.Data.Name + }) + + // alice claim the topic to be owned by payment team + var aliceClaimTicketID string + var aliceChannelClaimTicketID string + t.Run("alice claim topic", func(t *testing.T) { + if len(bookmarkedTopicIDs) == 0 { + t.Skip("no bookmarked topics from previous step") + } + topicID := bookmarkedTopicIDs[0] + claimReq := map[string]interface{}{ + "entity_id": topicID, + "group_id": groupPayment.ID, + "group_name": groupPayment.Name, + } + body, _ := json.Marshal(claimReq) + claimRequest, _ := http.NewRequest("POST", helpers.GetHost()+"/api/entity/claim", bytes.NewReader(body)) + claimRequest.Header.Set("Content-Type", "application/json") + claimRequest.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + claimResp, err := client.Do(claimRequest) + if err != nil { + t.Fatalf("failed to POST claim: %v", err) + } + defer claimResp.Body.Close() + if claimResp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(claimResp.Body) + t.Fatalf("unexpected status code for claim: %d, body: %s", claimResp.StatusCode, string(respBody)) + } + var claimResult struct { + Data struct { + ApplicationID string `json:"application_id"` + } `json:"data"` + } + err = json.NewDecoder(claimResp.Body).Decode(&claimResult) + if err == nil && claimResult.Data.ApplicationID != "" { + aliceClaimTicketID = claimResult.Data.ApplicationID + } + require.NotEmpty(t, aliceClaimTicketID) + }) + + // alice should be able to see application detail (from signup) + t.Run("alice application detail", func(t *testing.T) { + // Get alice's own applications + applicationsReq, _ := http.NewRequest("GET", helpers.GetHost()+"/api/tickets/list-my-applications", nil) + applicationsReq.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + applicationsResp, err := client.Do(applicationsReq) + if err != nil { + t.Fatalf("failed to GET /api/tickets/list-my-applications: %v", err) + } + defer applicationsResp.Body.Close() + if applicationsResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(applicationsResp.Body) + t.Fatalf("unexpected status code: %d, body: %s", applicationsResp.StatusCode, string(body)) + } + var applicationsResult struct { + Data struct { + Applications []struct { + ID string `json:"id"` + } `json:"applications"` + } `json:"data"` + } + err = json.NewDecoder(applicationsResp.Body).Decode(&applicationsResult) + if err != nil { + t.Fatalf("failed to decode applications response: %v", err) + } + if len(applicationsResult.Data.Applications) == 0 { + t.Fatalf("expected at least one application for alice") + } + // Try to get detail for the first application + appID := applicationsResult.Data.Applications[0].ID + detailReq, _ := http.NewRequest("GET", helpers.GetHost()+"/api/signup/app?id="+appID, nil) + detailReq.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + detailResp, err := client.Do(detailReq) + if err != nil { + t.Fatalf("failed to GET /api/signup/app: %v", err) + } + defer detailResp.Body.Close() + if detailResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(detailResp.Body) + t.Fatalf("unexpected status code: %d, body: %s", detailResp.StatusCode, string(body)) + } + var detail struct { + Data struct { + Application struct { + ID string `json:"id"` + } `json:"application"` + } `json:"data"` + } + err = json.NewDecoder(detailResp.Body).Decode(&detail) + if err != nil { + t.Fatalf("failed to decode application detail: %v", err) + } + if detail.Data.Application.ID != appID { + t.Fatalf("expected application ID %s, got %s", appID, detail.Data.Application.ID) + } + }) + + // in the topic detail, alice also claim the channel[0] to be owned by payment team + t.Run("alice claim channel", func(t *testing.T) { + if len(bookmarkedTopicIDs) == 0 { + t.Skip("no bookmarked topics from previous step") + } + if cachedNsqdHosts == "" || cachedTopicName == "" { + t.Skip("no nsqd hosts or topic name cached from topic detail") + } + // List channels for the topic with hosts param (using topic name) + channelsReq, _ := http.NewRequest( + "GET", + helpers.GetHost()+"/api/topic/nsq/list-channels?topic="+cachedTopicName+"&hosts="+cachedNsqdHosts, + nil, + ) + channelsReq.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + channelsResp, err := client.Do(channelsReq) + if err != nil { + t.Fatalf("failed to GET /api/topic/nsq/list-channels: %v", err) + } + defer channelsResp.Body.Close() + if channelsResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(channelsResp.Body) + t.Fatalf("unexpected status code: %d, body: %s", channelsResp.StatusCode, string(body)) + } + var channelsResult struct { + Data struct { + Channels []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"channels"` + } `json:"data"` + } + err = json.NewDecoder(channelsResp.Body).Decode(&channelsResult) + if err != nil { + t.Fatalf("failed to decode channels response: %v", err) + } + if len(channelsResult.Data.Channels) == 0 { + t.Skip("no channels found for topic") + } + channel := channelsResult.Data.Channels[0] + claimReq := map[string]interface{}{ + "entity_id": channel.ID, + "group_id": groupPayment.ID, + "group_name": groupPayment.Name, + } + body, _ := json.Marshal(claimReq) + claimRequest, _ := http.NewRequest("POST", helpers.GetHost()+"/api/entity/claim", bytes.NewReader(body)) + claimRequest.Header.Set("Content-Type", "application/json") + claimRequest.AddCookie(&http.Cookie{Name: "access_token", Value: aliceToken}) + claimResp, err := client.Do(claimRequest) + if err != nil { + t.Fatalf("failed to POST claim for channel: %v", err) + } + defer claimResp.Body.Close() + if claimResp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(claimResp.Body) + t.Fatalf("unexpected status code for channel claim: %d, body: %s", claimResp.StatusCode, string(respBody)) + } + var claimResult struct { + Data struct { + ApplicationID string `json:"application_id"` + } `json:"data"` + } + err = json.NewDecoder(claimResp.Body).Decode(&claimResult) + if err == nil && claimResult.Data.ApplicationID != "" { + aliceChannelClaimTicketID = claimResult.Data.ApplicationID + } + require.NotEmpty(t, aliceChannelClaimTicketID) + }) + + // charlie can list tickets + t.Run("charlie list tickets", func(t *testing.T) { + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/tickets/list-my-assignment?page=1&limit=100", nil) + req.AddCookie(&http.Cookie{Name: "access_token", Value: charlieToken}) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to GET /api/tickets/list-my-assignment: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + var result struct { + Data struct { + Tickets []struct { + ID string `json:"id"` + } `json:"applications"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(result.Data.Tickets) != 2 { + t.Fatalf("expected 2 tickets for charlie (both alice's claims), got %d", len(result.Data.Tickets)) + } + // Ensure both alice's claim tickets are present + foundTopic := false + foundChannel := false + for _, ticket := range result.Data.Tickets { + if ticket.ID == aliceClaimTicketID { + foundTopic = true + } + if ticket.ID == aliceChannelClaimTicketID { + foundChannel = true + } + } + if !foundTopic || !foundChannel { + t.Fatalf("expected to find both alice's claim tickets in charlie's assignment list") + } + }) + t.Run("charlie approve alice claims", func(t *testing.T) { + for _, ticketID := range []string{aliceClaimTicketID, aliceChannelClaimTicketID} { + if ticketID == "" { + t.Fatalf("no alice claim ticket to approve") + } + approveReq := map[string]interface{}{ + "action": "approve", + "application_id": ticketID, + } + body, _ := json.Marshal(approveReq) + approveRequest, _ := http.NewRequest("POST", helpers.GetHost()+"/api/tickets/action", bytes.NewReader(body)) + approveRequest.Header.Set("Content-Type", "application/json") + approveRequest.AddCookie(&http.Cookie{Name: "access_token", Value: charlieToken}) + approveResp, err := client.Do(approveRequest) + if err != nil { + t.Fatalf("failed to POST approve ticket: %v", err) + } + defer approveResp.Body.Close() + if approveResp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(approveResp.Body) + t.Fatalf("unexpected status code for approve: %d, body: %s", approveResp.StatusCode, string(respBody)) + } + } + }) + + // bob can list topics + t.Run("bob list topics", func(t *testing.T) { + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/topic/list-all-topics", nil) + req.AddCookie(&http.Cookie{Name: "access_token", Value: bobToken}) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to GET /api/topic/list-all-topics: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + var result struct { + Data struct { + Topics []struct { + ID string `json:"id"` + } `json:"topics"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(result.Data.Topics) == 0 { + t.Fatalf("expected at least one topic for bob") + } + }) + + // bob try to change the description(event trigger) of topic[0] -> should fail + t.Run("bob update topic forbidden", func(t *testing.T) { + if len(bookmarkedTopicIDs) == 0 { + t.Skip("no bookmarked topics from previous step") + } + topicID := bookmarkedTopicIDs[0] + updateReq := map[string]interface{}{ + "entity_id": topicID, + "description": "unauthorized update by bob", + } + body, _ := json.Marshal(updateReq) + updateRequest, _ := http.NewRequest("POST", helpers.GetHost()+"/api/entity/update-description", bytes.NewReader(body)) + updateRequest.Header.Set("Content-Type", "application/json") + updateRequest.AddCookie(&http.Cookie{Name: "access_token", Value: bobToken}) + updateResp, err := client.Do(updateRequest) + if err != nil { + t.Fatalf("failed to POST update-description: %v", err) + } + defer updateResp.Body.Close() + if updateResp.StatusCode == http.StatusOK { + t.Fatalf("expected forbidden, got status OK") + } + }) + + // bob try to pause topic[0] -> should fail + t.Run("bob pause topic forbidden", func(t *testing.T) { + if len(bookmarkedTopicIDs) == 0 { + t.Skip("no bookmarked topics from previous step") + } + topicID := bookmarkedTopicIDs[0] + pauseReq, _ := http.NewRequest("GET", helpers.GetHost()+"/api/topic/nsq/pause?id="+topicID+"&entity_id="+topicID, nil) + pauseReq.AddCookie(&http.Cookie{Name: "access_token", Value: bobToken}) + pauseResp, err := client.Do(pauseReq) + if err != nil { + t.Fatalf("failed to GET pause: %v", err) + } + defer pauseResp.Body.Close() + if pauseResp.StatusCode == http.StatusOK { + t.Fatalf("expected forbidden, got status OK") + } + }) + + // bob try to delete channel[0] -> should fail + t.Run("bob delete channel forbidden", func(t *testing.T) { + if len(bookmarkedTopicIDs) == 0 { + t.Skip("no bookmarked topics from previous step") + } + if cachedNsqdHosts == "" || cachedTopicName == "" { + t.Skip("no nsqd hosts or topic name cached from topic detail") + } + // List channels for the topic with hosts param (using topic name) + channelsReq, _ := http.NewRequest( + "GET", + helpers.GetHost()+"/api/topic/nsq/list-channels?topic="+cachedTopicName+"&hosts="+cachedNsqdHosts, + nil, + ) + channelsReq.AddCookie(&http.Cookie{Name: "access_token", Value: bobToken}) + channelsResp, err := client.Do(channelsReq) + if err != nil { + t.Fatalf("failed to GET /api/topic/nsq/list-channels: %v", err) + } + defer channelsResp.Body.Close() + if channelsResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(channelsResp.Body) + t.Fatalf("unexpected status code: %d, body: %s", channelsResp.StatusCode, string(body)) + } + var channelsResult struct { + Data struct { + Channels []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"channels"` + } `json:"data"` + } + err = json.NewDecoder(channelsResp.Body).Decode(&channelsResult) + if err != nil { + t.Fatalf("failed to decode channels response: %v", err) + } + if len(channelsResult.Data.Channels) == 0 { + t.Skip("no channels found for topic") + } + channel := channelsResult.Data.Channels[0] + deleteReq, _ := http.NewRequest("GET", helpers.GetHost()+"/api/channel/nsq/delete?id="+channel.ID+"&channel="+channel.Name+"&entity_id="+channel.ID, nil) + deleteReq.AddCookie(&http.Cookie{Name: "access_token", Value: bobToken}) + deleteResp, err := client.Do(deleteReq) + if err != nil { + t.Fatalf("failed to GET delete channel: %v", err) + } + defer deleteResp.Body.Close() + if deleteResp.StatusCode == http.StatusOK { + t.Fatalf("expected forbidden, got status OK") + } + }) + + // bob try to claim the topic[0] -> should be okay + var bobClaimTicketID string + t.Run("bob claim topic", func(t *testing.T) { + if len(bookmarkedTopicIDs) == 0 { + t.Skip("no bookmarked topics from previous step") + } + topicID := bookmarkedTopicIDs[0] + claimReq := map[string]interface{}{ + "entity_id": topicID, + "group_id": groupOrder.ID, + "group_name": groupOrder.Name, + } + body, _ := json.Marshal(claimReq) + claimRequest, _ := http.NewRequest("POST", helpers.GetHost()+"/api/entity/claim", bytes.NewReader(body)) + claimRequest.Header.Set("Content-Type", "application/json") + claimRequest.AddCookie(&http.Cookie{Name: "access_token", Value: bobToken}) + claimResp, err := client.Do(claimRequest) + if err != nil { + t.Fatalf("failed to POST claim: %v", err) + } + defer claimResp.Body.Close() + if claimResp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(claimResp.Body) + t.Fatalf("unexpected status code for claim: %d, body: %s", claimResp.StatusCode, string(respBody)) + } + var claimResult struct { + Data struct { + ApplicationID string `json:"application_id"` + } `json:"data"` + } + err = json.NewDecoder(claimResp.Body).Decode(&claimResult) + if err == nil && claimResult.Data.ApplicationID != "" { + bobClaimTicketID = claimResult.Data.ApplicationID + } + }) + + // charlie see detail of bob's ticket + t.Run("charlie ticket detail bob", func(t *testing.T) { + if bobClaimTicketID == "" { + t.Skip("no bob claim ticket to check detail") + } + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/tickets/detail?id="+bobClaimTicketID, nil) + req.AddCookie(&http.Cookie{Name: "access_token", Value: charlieToken}) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to GET /api/tickets/detail: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + var detail struct { + Data struct { + Ticket struct { + ID string `json:"id"` + } `json:"ticket"` + Assignees []struct { + UserID string `json:"user_id"` + Username string `json:"username"` + } `json:"assignees"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&detail) + if err != nil { + t.Fatalf("failed to decode ticket detail: %v", err) + } + if detail.Data.Ticket.ID != bobClaimTicketID { + t.Fatalf("expected ticket ID %s, got %s", bobClaimTicketID, detail.Data.Ticket.ID) + } + // Assignee should be charlie (by username or id, depending on API) + if detail.Data.Assignees[0].Username != "charlie" { + t.Fatalf("expected assignee to be charlie, got %s", detail.Data.Assignees[0].Username) + } + }) + + // charlie should see the ticket for topic claim from bob and reject it + t.Run("charlie reject bob claim", func(t *testing.T) { + // Reject bob's claim ticket + if bobClaimTicketID == "" { + t.Skip("no bob claim ticket to reject") + } + rejectReq := map[string]interface{}{ + "action": "reject", + "application_id": bobClaimTicketID, + } + body, _ := json.Marshal(rejectReq) + rejectRequest, _ := http.NewRequest("POST", helpers.GetHost()+"/api/tickets/action", bytes.NewReader(body)) + rejectRequest.Header.Set("Content-Type", "application/json") + rejectRequest.AddCookie(&http.Cookie{Name: "access_token", Value: charlieToken}) + rejectResp, err := client.Do(rejectRequest) + if err != nil { + t.Fatalf("failed to POST reject ticket: %v", err) + } + defer rejectResp.Body.Close() + if rejectResp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(rejectResp.Body) + t.Fatalf("unexpected status code for reject: %d, body: %s", rejectResp.StatusCode, string(respBody)) + } + }) + +} diff --git a/infra/test_script/logged-in/signup_test.go b/infra/test_script/logged-in/signup_test.go new file mode 100644 index 0000000..e639647 --- /dev/null +++ b/infra/test_script/logged-in/signup_test.go @@ -0,0 +1,212 @@ +package loggedin + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + helpers "github.com/jekiapp/topic-master/infra/test_script/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type UserSignupReq struct { + Username string `json:"username"` + Name string `json:"name"` + Password string `json:"password"` + ConfirmPassword string `json:"confirm_password"` + Reason string `json:"reason"` + GroupID string `json:"group_id"` + GroupName string `json:"group_name"` + GroupRole string `json:"group_role"` +} + +var userSingupReqs = map[string]UserSignupReq{ + "alice": { + Username: "alice", + Name: "Alice Smith", + Password: "alicepass", + ConfirmPassword: "alicepass", + Reason: "I want to join payment team", + GroupRole: "member", + }, + "bob": { + Username: "bob", + Name: "Bob Smith", + Password: "bobpass", + ConfirmPassword: "bobpass", + Reason: "I want to join order team", + GroupRole: "member", + }, + "charlie": { + Username: "charlie", + Name: "Charlie Smith", + Password: "charliepass", + ConfirmPassword: "charliepass", + Reason: "I want to join payment team", + GroupRole: "admin", + }, +} + +// UserSignup performs the signup flow and returns the access token after approval. +func UserSignup(t *testing.T, username, rootToken string, group helpers.TestGroup) string { + client := &http.Client{} + + var applicationID string + + t.Run("signup", func(t *testing.T) { + signupReq := userSingupReqs[username] + signupReq.GroupID = group.ID + signupReq.GroupName = group.Name + body, _ := json.Marshal(signupReq) + resp, err := client.Post(helpers.GetHost()+"/api/signup", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var signupResp struct { + Data struct { + ApplicationID string `json:"application_id"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&signupResp) + require.NoError(t, err) + require.NotEmpty(t, signupResp.Data.ApplicationID, "application_id should be present") + applicationID = signupResp.Data.ApplicationID + }) + + t.Run("check detail", func(t *testing.T) { + require.NotEmpty(t, applicationID, "application_id should be set from signup subtest") + appDetailReq, _ := http.NewRequest("GET", helpers.GetHost()+"/api/signup/app?id="+applicationID, nil) + appDetailResp, err := client.Do(appDetailReq) + require.NoError(t, err) + defer appDetailResp.Body.Close() + require.Equal(t, http.StatusOK, appDetailResp.StatusCode) + var appDetail struct { + Data struct { + Application struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Status string `json:"status"` + Reason string `json:"reason"` + Type string `json:"type"` + } `json:"application"` + User struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"user"` + } `json:"data"` + } + bodyBytes, _ := io.ReadAll(appDetailResp.Body) + err = json.Unmarshal(bodyBytes, &appDetail) + require.NoError(t, err, "failed to decode signup app detail: %s", string(bodyBytes)) + require.Equal(t, applicationID, appDetail.Data.Application.ID) + require.Equal(t, username, appDetail.Data.User.Username) + require.Equal(t, userSingupReqs[username].Name, appDetail.Data.User.Name) + }) + + t.Run("root assignment list", func(t *testing.T) { + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/tickets/list-my-assignment", nil) + req.Header.Set("Authorization", "Bearer "+rootToken) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var assignmentResp struct { + Data struct { + Applications []struct { + ID string `json:"id"` + ApplicantName string `json:"applicant_name"` + } `json:"applications"` + HasNext bool `json:"has_next"` + } `json:"data"` + } + body, _ := io.ReadAll(resp.Body) + err = json.Unmarshal(body, &assignmentResp) + require.NoError(t, err, "failed to decode assignment list: %s", string(body)) + found := false + for _, app := range assignmentResp.Data.Applications { + if app.ID == applicationID { + found = true + break + } + } + require.True(t, found, "application_id %s should be present in root's assignment list", applicationID) + }) + + t.Run("root open application detail", func(t *testing.T) { + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/tickets/detail?id="+applicationID, nil) + req.Header.Set("Authorization", "Bearer "+rootToken) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var detailResp struct { + Data struct { + Ticket struct { + ID string `json:"id"` + } `json:"ticket"` + Applicant struct { + Username string `json:"username"` + Name string `json:"name"` + } `json:"applicant"` + } `json:"data"` + } + body, _ := io.ReadAll(resp.Body) + err = json.Unmarshal(body, &detailResp) + require.NoError(t, err, "failed to decode ticket detail: %s", string(body)) + require.Equal(t, applicationID, detailResp.Data.Ticket.ID) + require.Equal(t, username, detailResp.Data.Applicant.Username) + require.Equal(t, userSingupReqs[username].Name, detailResp.Data.Applicant.Name) + }) + + t.Run("root approve application", func(t *testing.T) { + approveReq := map[string]interface{}{ + "action": "approve", + "application_id": applicationID, + } + body, _ := json.Marshal(approveReq) + req, _ := http.NewRequest("POST", helpers.GetHost()+"/api/tickets/action", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+rootToken) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + var approveResp struct { + Data struct { + Status string `json:"status"` + Message string `json:"message"` + } `json:"data"` + } + respBody, _ := io.ReadAll(resp.Body) + err = json.Unmarshal(respBody, &approveResp) + require.NoError(t, err, "failed to decode approve response: %s", string(respBody)) + require.Equal(t, "success", approveResp.Data.Status) + }) + + var accessToken string + t.Run("user can login after approval", func(t *testing.T) { + loginResp, cookies := helpers.LoginUser( + t, + client, + username, + userSingupReqs[username].Password, + ) + defer loginResp.Body.Close() + if !assert.Equal(t, http.StatusOK, loginResp.StatusCode) { + body, _ := io.ReadAll(loginResp.Body) + fmt.Println(string(body)) + } + for _, c := range cookies { + if c.Name == "access_token" { + accessToken = c.Value + break + } + } + }) + return accessToken +} diff --git a/infra/test_script/non-login/channel_test.go b/infra/test_script/non-login/channel_test.go new file mode 100644 index 0000000..112a38a --- /dev/null +++ b/infra/test_script/non-login/channel_test.go @@ -0,0 +1,202 @@ +package nonlogin + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "testing" + + helpers "github.com/jekiapp/topic-master/infra/test_script/helpers" + "github.com/stretchr/testify/assert" +) + +var channelTestHost = helpers.GetHost() + +type Channel struct { + ID string `json:"id"` + Name string `json:"name"` + GroupOwner string `json:"group_owner"` + Description string `json:"description"` + Topic string `json:"topic"` + IsBookmarked bool `json:"is_bookmarked"` + IsPaused bool `json:"is_paused"` + IsFreeAction bool `json:"is_free_action"` +} + +func listChannels(t *testing.T, topic Topic) []Channel { + if len(topic.NsqdHosts) == 0 { + t.Skip("no nsqd hosts for channel list") + } + hosts := "" + for i, h := range topic.NsqdHosts { + hosts += h.Address + if i < len(topic.NsqdHosts)-1 { + hosts += "," + } + } + url := fmt.Sprintf("%s/api/topic/nsq/list-channels?topic=%s&hosts=%s", channelTestHost, topic.Name, hosts) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET list-channels: %v", err) + } + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "unexpected status code for list-channels") + var result struct { + Data struct { + Channels []Channel `json:"channels"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode list-channels: %v", err) + } + return result.Data.Channels +} + +func pauseChannelShouldSucceed(t *testing.T, channel Channel) { + url := fmt.Sprintf("%s/api/channel/nsq/pause?id=%s&channel=%s&entity_id=%s", channelTestHost, channel.ID, channel.Name, channel.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET pause channel: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + t.Logf("pauseChannelShouldSucceed response body: %s", string(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, "pause channel should succeed") + var result map[string]interface{} + _ = json.Unmarshal(body, &result) + assert.Equal(t, "success", result["status"], "pause channel response status should be success") +} + +func resumeChannelShouldSucceed(t *testing.T, channel Channel) { + url := fmt.Sprintf("%s/api/channel/nsq/resume?id=%s&channel=%s&entity_id=%s", channelTestHost, channel.ID, channel.Name, channel.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET resume channel: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + t.Logf("resumeChannelShouldSucceed response body: %s", string(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, "resume channel should succeed") + var result map[string]interface{} + _ = json.Unmarshal(body, &result) + assert.Equal(t, "success", result["status"], "resume channel response status should be success") +} + +func emptyChannelShouldSucceed(t *testing.T, channel Channel) { + url := fmt.Sprintf("%s/api/channel/nsq/empty?id=%s&channel=%s&entity_id=%s", channelTestHost, channel.ID, channel.Name, channel.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET empty channel: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + t.Logf("emptyChannelShouldSucceed response body: %s", string(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, "empty channel should succeed") + var result map[string]interface{} + _ = json.Unmarshal(body, &result) + assert.Equal(t, "success", result["status"], "empty channel response status should be success") +} + +func deleteChannelShouldSucceed(t *testing.T, channel Channel) { + url := fmt.Sprintf("%s/api/channel/nsq/delete?id=%s&channel=%s&entity_id=%s", channelTestHost, channel.ID, channel.Name, channel.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET delete channel: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + t.Logf("deleteChannelShouldSucceed response body: %s", string(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, "delete channel should succeed") + var result map[string]interface{} + _ = json.Unmarshal(body, &result) + assert.Equal(t, "success", result["status"], "delete channel response status should be success") +} + +func claimChannelShouldFail(t *testing.T, channel Channel) { + claimReq := map[string]interface{}{ + "entity_id": channel.ID, + } + claimBody, _ := json.Marshal(claimReq) + claimResp, err := http.Post( + fmt.Sprintf("%s/api/entity/claim", channelTestHost), + "application/json", + bytes.NewReader(claimBody), + ) + if err != nil { + t.Fatalf("failed to POST claim channel: %v", err) + } + defer claimResp.Body.Close() + body, _ := io.ReadAll(claimResp.Body) + t.Logf("claimChannelShouldFail response body: %s", string(body)) + assert.NotEqual(t, http.StatusOK, claimResp.StatusCode, "claim channel should fail") +} + +func bookmarkChannelShouldFail(t *testing.T, channel Channel) { + bookmarkReq := map[string]interface{}{ + "entity_id": channel.ID, + } + bookmarkBody, _ := json.Marshal(bookmarkReq) + bookmarkResp, err := http.Post( + fmt.Sprintf("%s/api/entity/toggle-bookmark", channelTestHost), + "application/json", + bytes.NewReader(bookmarkBody), + ) + if err != nil { + t.Fatalf("failed to POST bookmark channel: %v", err) + } + defer bookmarkResp.Body.Close() + body, _ := io.ReadAll(bookmarkResp.Body) + t.Logf("bookmarkChannelShouldFail response body: %s", string(body)) + assert.NotEqual(t, http.StatusOK, bookmarkResp.StatusCode, "bookmark channel should fail") +} + +func TestChannelIntegrationFlow(t *testing.T) { + if envHost := os.Getenv("TOPIC_MASTER_HOST"); envHost != "" { + channelTestHost = envHost + } + var topics []Topic + t.Run("getAllTopicsForChannel", func(t *testing.T) { + alltopics := getAllTopics(t) + if len(alltopics) == 0 { + t.Fatalf("no topics to test channel API") + } + topics = alltopics + }) + + var topicDetail Topic + t.Run("checkTopicDetail", func(t *testing.T) { + topicDetail = checkTopicDetail(t, topics[3]) + }) + + var channels []Channel + var channel Channel + t.Run("listChannels", func(t *testing.T) { + channels = listChannels(t, topicDetail) + if len(channels) == 0 { + t.Fatalf("no channels to test") + } + channel = channels[0] + }) + + t.Run("pauseChannelShouldSucceed", func(t *testing.T) { + pauseChannelShouldSucceed(t, channel) + }) + t.Run("resumeChannelShouldSucceed", func(t *testing.T) { + resumeChannelShouldSucceed(t, channel) + }) + t.Run("emptyChannelShouldSucceed", func(t *testing.T) { + emptyChannelShouldSucceed(t, channel) + }) + t.Run("claimChannelShouldFail", func(t *testing.T) { + claimChannelShouldFail(t, channel) + }) + t.Run("bookmarkChannelShouldFail", func(t *testing.T) { + bookmarkChannelShouldFail(t, channel) + }) + t.Run("deleteChannelShouldSucceed", func(t *testing.T) { + deleteChannelShouldSucceed(t, channel) + }) +} diff --git a/infra/test_script/non-login/topics_test.go b/infra/test_script/non-login/topics_test.go new file mode 100644 index 0000000..5322330 --- /dev/null +++ b/infra/test_script/non-login/topics_test.go @@ -0,0 +1,431 @@ +package nonlogin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "testing" + "time" + + helpers "github.com/jekiapp/topic-master/infra/test_script/helpers" + "github.com/stretchr/testify/assert" + + nhooyrws "nhooyr.io/websocket" +) + +var topicMasterHost = helpers.GetHost() + +type Topic struct { + ID string `json:"id"` + Name string `json:"name"` + EventTrigger string `json:"event_trigger"` + GroupOwner string `json:"group_owner"` + Bookmarked bool `json:"bookmarked"` + NsqdHosts []struct { + Address string `json:"address"` + } `json:"nsqd_hosts"` +} + +func getAllTopics(t *testing.T) []Topic { + resp, err := http.Get(topicMasterHost + "/api/topic/list-all-topics") + if err != nil { + t.Fatalf("failed to GET /api/topic/list-all-topics: %v", err) + } + defer resp.Body.Close() + + assert.Equal( + t, + http.StatusOK, + resp.StatusCode, + "unexpected status code", + ) + + var result struct { + Data struct { + Topics []Topic `json:"topics"` + } `json:"data"` + } + + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + assert.NotNil( + t, + result.Data.Topics, + "topics should not be nil", + ) + + for _, topic := range result.Data.Topics { + assert.NotEmpty(t, topic.ID, "topic ID should not be empty") + assert.NotEmpty(t, topic.Name, "topic name should not be empty") + assert.False(t, topic.Bookmarked, "topic bookmarked should be false") + } + + return result.Data.Topics +} + +func checkTopicDetail(t *testing.T, topic Topic) Topic { + url := fmt.Sprintf("%s/api/topic/detail?topic=%s", topicMasterHost, topic.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET /api/topic/detail: %v", err) + } + defer resp.Body.Close() + + assert.Equal( + t, + http.StatusOK, + resp.StatusCode, + "unexpected status code", + ) + + var detailResp struct { + Data Topic `json:"data"` + } + + err = json.NewDecoder(resp.Body).Decode(&detailResp) + if err != nil { + t.Fatalf("failed to decode detail response: %v", err) + } + + assert.Equal(t, topic.ID, detailResp.Data.ID, "detail ID should match topic ID") + assert.Equal(t, topic.Name, detailResp.Data.Name, "detail name should match topic name") + // Optionally check other fields if needed + return detailResp.Data +} + +func editEventTrigger(t *testing.T, topic Topic) { + newEventTrigger := "integration test event trigger" + updateReq := map[string]interface{}{ + "entity_id": topic.ID, + "description": newEventTrigger, + } + updateBody, _ := json.Marshal(updateReq) + updateResp, err := http.Post( + fmt.Sprintf("%s/api/entity/update-description?entity_id=%s", topicMasterHost, topic.ID), + "application/json", + bytes.NewReader(updateBody), + ) + if err != nil { + t.Fatalf("failed to POST update-description: %v", err) + } + defer updateResp.Body.Close() + assert.Equal(t, http.StatusOK, updateResp.StatusCode, "unexpected status code for update-description") + + verifyResp, err := http.Get(fmt.Sprintf("%s/api/topic/detail?topic=%s", topicMasterHost, topic.ID)) + if err != nil { + t.Fatalf("failed to GET detail after update: %v", err) + } + defer verifyResp.Body.Close() + var verifyDetail struct { + Data struct { + EventTrigger string `json:"event_trigger"` + } `json:"data"` + } + _ = json.NewDecoder(verifyResp.Body).Decode(&verifyDetail) + assert.Equal(t, newEventTrigger, verifyDetail.Data.EventTrigger, "event trigger should be updated") +} + +func checkTopicStats(t *testing.T, topic Topic) { + + if len(topic.NsqdHosts) == 0 { + t.Skip("no nsqd hosts for topic stats") + } + hosts := "" + for i, h := range topic.NsqdHosts { + hosts += h.Address + if i < len(topic.NsqdHosts)-1 { + hosts += "," + } + } + statsURL := fmt.Sprintf("%s/api/topic/stats?topic=%s&hosts=%s", topicMasterHost, topic.Name, hosts) + statsResp, err := http.Get(statsURL) + if err != nil { + t.Fatalf("failed to GET topic stats: %v", err) + } + defer statsResp.Body.Close() + assert.Equal(t, http.StatusOK, statsResp.StatusCode, "unexpected status code for topic stats") + var stats struct { + Data struct { + Depth int `json:"depth"` + Messages int `json:"messages"` + ChannelStats interface{} `json:"channel_stats"` + } `json:"data"` + } + err = json.NewDecoder(statsResp.Body).Decode(&stats) + if err != nil { + t.Fatalf("failed to decode topic stats: %v", err) + } + assert.GreaterOrEqual(t, stats.Data.Depth, 0, "depth should be >= 0") + assert.GreaterOrEqual(t, stats.Data.Messages, 0, "messages should be >= 0") +} + +func publishTopic(t *testing.T, topic Topic) { + + if len(topic.NsqdHosts) == 0 { + t.Skip("no nsqd hosts for publish topic") + } + publishReq := map[string]interface{}{ + "topic": topic.Name, + "message": "integration test message", + "nsqd_hosts": []string{topic.NsqdHosts[0].Address}, + } + publishBody, _ := json.Marshal(publishReq) + publishURL := fmt.Sprintf("%s/api/topic/publish?entity_id=%s", topicMasterHost, topic.ID) + publishResp, err := http.Post( + publishURL, + "application/json", + bytes.NewReader(publishBody), + ) + if err != nil { + t.Fatalf("failed to POST publish: %v", err) + } + defer publishResp.Body.Close() + assert.Equal(t, http.StatusOK, publishResp.StatusCode, "unexpected status code for publish") + var pubResp struct { + Data struct { + Message string `json:"message"` + } `json:"data"` + } + err = json.NewDecoder(publishResp.Body).Decode(&pubResp) + if err != nil { + t.Fatalf("failed to decode publish response: %v", err) + } + assert.Contains(t, pubResp.Data.Message, "published", "publish response should indicate success") +} + +func claimShouldFail(t *testing.T, topic Topic) { + claimReq := map[string]interface{}{ + "entity_id": topic.ID, + } + claimBody, _ := json.Marshal(claimReq) + claimResp, err := http.Post( + fmt.Sprintf("%s/api/entity/claim", topicMasterHost), + "application/json", + bytes.NewReader(claimBody), + ) + if err != nil { + t.Fatalf("failed to POST claim: %v", err) + } + defer claimResp.Body.Close() + body, _ := io.ReadAll(claimResp.Body) + t.Logf("claimShouldFail response body: %s", string(body)) + assert.NotEqual(t, http.StatusOK, claimResp.StatusCode, "claim should fail") +} + +func bookmarkShouldFail(t *testing.T, topic Topic) { + bookmarkReq := map[string]interface{}{ + "entity_id": topic.ID, + } + bookmarkBody, _ := json.Marshal(bookmarkReq) + bookmarkResp, err := http.Post( + fmt.Sprintf("%s/api/entity/toggle-bookmark", topicMasterHost), + "application/json", + bytes.NewReader(bookmarkBody), + ) + if err != nil { + t.Fatalf("failed to POST bookmark: %v", err) + } + defer bookmarkResp.Body.Close() + body, _ := io.ReadAll(bookmarkResp.Body) + t.Logf("bookmarkShouldFail response body: %s", string(body)) + assert.NotEqual(t, http.StatusOK, bookmarkResp.StatusCode, "bookmark should fail") +} + +func pauseShouldSucceed(t *testing.T, topic Topic) { + url := fmt.Sprintf("%s/api/topic/nsq/pause?id=%s&entity_id=%s", topicMasterHost, topic.ID, topic.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET pause: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + t.Logf("pauseShouldSucceed response body: %s", string(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, "pause should succeed") + var result map[string]interface{} + err = json.Unmarshal(body, &result) + if assert.NoError(t, err, "pause response should be valid JSON") { + assert.Equal(t, "success", result["status"], "pause response status should be success") + } +} + +func resumeShouldSucceed(t *testing.T, topic Topic) { + url := fmt.Sprintf("%s/api/topic/nsq/resume?id=%s&entity_id=%s", topicMasterHost, topic.ID, topic.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET resume: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + t.Logf("resumeShouldSucceed response body: %s", string(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, "resume should succeed") + var result map[string]interface{} + err = json.Unmarshal(body, &result) + if assert.NoError(t, err, "resume response should be valid JSON") { + assert.Equal(t, "success", result["status"], "resume response status should be success") + } +} + +func emptyShouldSucceed(t *testing.T, topic Topic) { + url := fmt.Sprintf("%s/api/topic/nsq/empty?id=%s&entity_id=%s", topicMasterHost, topic.ID, topic.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET empty: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + t.Logf("emptyShouldSucceed response body: %s", string(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, "empty should succeed") + var result map[string]interface{} + err = json.Unmarshal(body, &result) + if assert.NoError(t, err, "empty response should be valid JSON") { + assert.Equal(t, "success", result["status"], "empty response status should be success") + } +} + +func tailTopic(t *testing.T, topic Topic, messageCh chan<- string, errCh chan<- error) { + if len(topic.NsqdHosts) == 0 { + errCh <- nil // skip if no hosts + return + } + hosts := "" + for i, h := range topic.NsqdHosts { + hosts += h.Address + if i < len(topic.NsqdHosts)-1 { + hosts += "," + } + } + + // Build ws URL with all required params + wsBase := "ws://" + topicMasterHost[len("http://"):] + wsTailURL := fmt.Sprintf("%s/api/topic/tail?topic=%s&limit_msg=1&nsqd_hosts=%s&entity_id=%s", + wsBase, + topic.Name, + url.QueryEscape(hosts), + topic.ID, + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, _, err := nhooyrws.Dial(ctx, wsTailURL, nil) + if err != nil { + errCh <- err + return + } + defer conn.Close(nhooyrws.StatusNormalClosure, "done") + + _, data, err := conn.Read(ctx) + if err != nil { + errCh <- err + return + } + + t.Logf("raw ws message: %q", data) + + // Convert to string, strip trailing \x1e, and send + msg := string(bytes.Trim(data, "\x1e")) + messageCh <- msg +} + +func deleteTopicShouldSucceed(t *testing.T, topic Topic) { + url := fmt.Sprintf("%s/api/topic/delete?id=%s&entity_id=%s", topicMasterHost, topic.ID, topic.ID) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("failed to GET /api/topic/delete: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + t.Logf("deleteTopic response body: %s", string(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, "delete topic should succeed") + var result map[string]interface{} + err = json.Unmarshal(body, &result) + if assert.NoError(t, err, "delete response should be valid JSON") { + assert.Equal(t, "success", result["status"], "delete response status should be success") + } + // Ensure topic is gone + topicsAfter := getAllTopics(t) + for _, tp := range topicsAfter { + assert.NotEqual(t, topic.ID, tp.ID, "deleted topic should not be in the list") + } +} + +func TestTopicIntegrationFlow(t *testing.T) { + if envHost := os.Getenv("TOPIC_MASTER_HOST"); envHost != "" { + topicMasterHost = envHost + } + var topics []Topic + t.Run("getAllTopics", func(t *testing.T) { + alltopics := getAllTopics(t) + if len(alltopics) == 0 { + t.Fatalf("no topics to test detail API") + } + topics = alltopics + }) + + var topicDetail Topic + t.Run("checkTopicDetail", func(t *testing.T) { + topicDetail = checkTopicDetail(t, topics[4]) + }) + + t.Run("editEventTrigger", func(t *testing.T) { + editEventTrigger(t, topicDetail) + }) + + t.Run("checkTopicStats", func(t *testing.T) { + checkTopicStats(t, topicDetail) + }) + + t.Run("tail and publish", func(t *testing.T) { + messageCh := make(chan string, 1) + errCh := make(chan error, 1) + go tailTopic(t, topicDetail, messageCh, errCh) + // Wait a moment to ensure tail is listening before publish + time.Sleep(300 * time.Millisecond) + // Now publish + publishTopic(t, topicDetail) + select { + case msg := <-messageCh: + assert.Contains(t, msg, "integration test message", "tail should receive published message") + case err := <-errCh: + if err != nil { + t.Fatalf("tailTopic error: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for tail message") + } + }) + + t.Run("claimShouldFail", func(t *testing.T) { + claimShouldFail(t, topicDetail) + }) + + t.Run("bookmarkShouldFail", func(t *testing.T) { + bookmarkShouldFail(t, topicDetail) + }) + + t.Run("pauseShouldSucceed", func(t *testing.T) { + pauseShouldSucceed(t, topicDetail) + }) + + t.Run("resumeShouldSucceed", func(t *testing.T) { + resumeShouldSucceed(t, topicDetail) + }) + + t.Run("emptyShouldSucceed", func(t *testing.T) { + emptyShouldSucceed(t, topicDetail) + }) + + // Delete the topic at the end + t.Run("deleteTopicShouldSucceed", func(t *testing.T) { + deleteTopicShouldSucceed(t, topicDetail) + }) + +} diff --git a/infra/test_script/root/groups_test.go b/infra/test_script/root/groups_test.go new file mode 100644 index 0000000..c230da7 --- /dev/null +++ b/infra/test_script/root/groups_test.go @@ -0,0 +1,130 @@ +package root + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "testing" + + helpers "github.com/jekiapp/topic-master/infra/test_script/helpers" + "github.com/stretchr/testify/require" +) + +func TestRootUserGroupListIntegration(t *testing.T) { + groupTestHost := helpers.GetHost() + if envHost := os.Getenv("TOPIC_MASTER_HOST"); envHost != "" { + groupTestHost = envHost + } + + const randomSuffix = "964" + + client := &http.Client{} + accessToken := helpers.LoginAsRoot(t, client, groupTestHost) + + var createdGroups []helpers.TestGroup + + t.Run("group list should only have root", func(t *testing.T) { + groups, err := helpers.GetAllGroups(t, client, groupTestHost, accessToken) + require.NoError(t, err) + var rootCount int + for _, g := range groups { + if g.Name == "root" { + rootCount++ + } + } + require.Equal(t, 1, rootCount, "should only have one group (root)") + }) + + t.Run("create 3 groups", func(t *testing.T) { + groupNames := []string{"engineering-" + randomSuffix, "marketing-" + randomSuffix, "support-" + randomSuffix} + descriptions := []string{"Engineering Team", "Marketing Team", "Support Team"} + for i := 0; i < 3; i++ { + createdGroup := helpers.CreateGroup(t, client, groupTestHost, accessToken, groupNames[i], descriptions[i]) + createdGroups = append(createdGroups, createdGroup) + } + require.Len(t, createdGroups, 3, "should have 3 created groups (excluding root)") + }) + + t.Run("edit the first group, change the description", func(t *testing.T) { + require.NotEmpty(t, createdGroups) + firstGroup := createdGroups[0] + newDesc := "Updated Engineering Description" + editReq := map[string]string{ + "id": firstGroup.ID, + "description": newDesc, + } + body, _ := json.Marshal(editReq) + req, _ := http.NewRequest("POST", groupTestHost+"/api/group/update-group-by-id", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + // Verify update + groups, err := helpers.GetAllGroups(t, client, groupTestHost, accessToken) + require.NoError(t, err) + var found bool + for _, g := range groups { + if g.ID == firstGroup.ID { + found = true + require.Equal(t, newDesc, g.Description) + } + } + require.True(t, found, "edited group should be found in list") + }) + + t.Run("delete the second group", func(t *testing.T) { + require.Len(t, createdGroups, 3) + secondGroup := createdGroups[1] + deleteReq := map[string]string{ + "id": secondGroup.ID, + } + body, _ := json.Marshal(deleteReq) + req, _ := http.NewRequest("POST", groupTestHost+"/api/group/delete-group", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + // Verify deletion + groups, err := helpers.GetAllGroups(t, client, groupTestHost, accessToken) + require.NoError(t, err) + for _, g := range groups { + require.NotEqual(t, secondGroup.ID, g.ID, "deleted group should not be in the list") + } + }) + + t.Run("deleting root group should be failed", func(t *testing.T) { + groups, err := helpers.GetAllGroups(t, client, groupTestHost, accessToken) + require.NoError(t, err) + var rootGroup *helpers.TestGroup + for _, g := range groups { + if g.Name == "root" { + rootGroup = &g + break + } + } + require.NotNil(t, rootGroup, "root group should exist") + deleteReq := map[string]string{ + "id": rootGroup.ID, + } + body, _ := json.Marshal(deleteReq) + req, _ := http.NewRequest("POST", groupTestHost+"/api/group/delete-group", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + // Should not be 200 OK for forbidden delete + require.NotEqual(t, http.StatusOK, resp.StatusCode, "deleting root group should not be allowed") + }) + + // delete group engineering and support + t.Run("delete group engineering and support", func(t *testing.T) { + helpers.DeleteGroup(t, client, accessToken, createdGroups[0].ID) + helpers.DeleteGroup(t, client, accessToken, createdGroups[2].ID) + }) +} diff --git a/infra/test_script/root/user_test.go b/infra/test_script/root/user_test.go new file mode 100644 index 0000000..b3727c1 --- /dev/null +++ b/infra/test_script/root/user_test.go @@ -0,0 +1,173 @@ +package root + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/jekiapp/topic-master/infra/test_script/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func editUserName(t *testing.T, client *http.Client, accessToken string, user helpers.User, newName string) { + editReq := map[string]interface{}{ + "username": user.Username, + "groups": user.GroupDetails, + "name": newName, + } + body, _ := json.Marshal(editReq) + req, _ := http.NewRequest("POST", helpers.GetHost()+"/api/user/update", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + if !assert.Equal(t, http.StatusOK, resp.StatusCode) { + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + } +} + +func addUserToGroup(t *testing.T, client *http.Client, accessToken string, user helpers.User, groups []helpers.GroupsReq) { + editReq := map[string]interface{}{ + "username": user.Username, + "name": user.Name, + "groups": groups, + } + body, _ := json.Marshal(editReq) + req, _ := http.NewRequest("POST", helpers.GetHost()+"/api/user/update", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "access_token", Value: accessToken}) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + if !assert.Equal(t, http.StatusOK, resp.StatusCode) { + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + } +} + +func TestRootUserUserIntegration(t *testing.T) { + const randomSuffix = "8382" + + client := &http.Client{} + accessToken := helpers.LoginAsRoot(t, client, helpers.GetHost()) + + var createdGroups []helpers.TestGroup + var createdUsers []helpers.User + + t.Run("user list should only have root", func(t *testing.T) { + users, err := helpers.GetAllUsers(client, accessToken) + require.NoError(t, err) + var rootCount int + for _, u := range users { + if u.Username == "root" { + rootCount++ + } + } + require.Equal(t, 1, rootCount, "should only have one user (root)") + }) + + t.Run("create 2 groups", func(t *testing.T) { + groupNames := []string{"engineering-user-" + randomSuffix, "marketing-user-" + randomSuffix} + descriptions := []string{"Engineering Team for user test", "Marketing Team for user test"} + for i := 0; i < 2; i++ { + g := helpers.CreateGroup(t, client, helpers.GetHost(), accessToken, groupNames[i], descriptions[i]) + createdGroups = append(createdGroups, g) + } + require.Len(t, createdGroups, 2, "should have 2 created groups") + }) + + t.Run("get group list", func(t *testing.T) { + groups, err := helpers.GetAllGroups(t, client, helpers.GetHost(), accessToken) + require.NoError(t, err) + var found int + for _, g := range groups { + if g.Name == "engineering-user-"+randomSuffix || g.Name == "marketing-user-"+randomSuffix { + found++ + } + } + require.Equal(t, 2, found, "should have engineering-user and marketing-user groups") + }) + + t.Run("create 4 users, 2 for each group with admin and member role", func(t *testing.T) { + userInputs := []struct { + Username string + Name string + Password string + Group helpers.TestGroup + Role string + }{ + {"alice-" + randomSuffix, "Alice Smith", "alicepass", createdGroups[0], "admin"}, + {"bob-" + randomSuffix, "Bob Jones", "bobpass", createdGroups[0], "member"}, + {"carol-" + randomSuffix, "Carol White", "carolpass", createdGroups[1], "admin"}, + {"dave-" + randomSuffix, "Dave Black", "davepass", createdGroups[1], "member"}, + } + for _, input := range userInputs { + u, err := helpers.CreateUser(client, accessToken, input.Username, input.Name, input.Password, []helpers.GroupsReq{{GroupID: input.Group.ID, Role: input.Role}}) + require.NoError(t, err) + createdUsers = append(createdUsers, u) + } + require.Len(t, createdUsers, 4, "should have 4 created users") + }) + + t.Run("edit 1st user, change the name", func(t *testing.T) { + require.NotEmpty(t, createdUsers) + firstUser := createdUsers[0] + newName := "Alice Cooper" + editUserName(t, client, accessToken, firstUser, newName) + // Verify + users, err := helpers.GetAllUsers(client, accessToken) + require.NoError(t, err) + var found bool + for _, u := range users { + if u.ID == firstUser.ID { + found = true + require.Equal(t, newName, u.Name) + } + } + require.True(t, found, "edited user should be found in list") + }) + + t.Run("edit 2nd user, add to the 2nd group as member", func(t *testing.T) { + require.Len(t, createdUsers, 4) + secondUser := createdUsers[1] + addUserToGroup(t, client, accessToken, secondUser, []helpers.GroupsReq{{GroupID: createdGroups[1].ID, Role: "member"}}) + // Verify + users, err := helpers.GetAllUsers(client, accessToken) + require.NoError(t, err) + var found bool + for _, u := range users { + if u.ID == secondUser.ID { + found = true + var inGroup bool + for _, g := range u.GroupDetails { + if g.GroupID == createdGroups[1].ID { + inGroup = true + } + } + require.True(t, inGroup, "user should be in the 2nd group") + } + } + require.True(t, found, "edited user should be found in list") + }) + + t.Cleanup(func() { + // Clean up created users (except root) + for _, u := range createdUsers { + if u.Username != "root" { + helpers.DeleteUser(t, client, accessToken, u.ID) + } + } + // Clean up created groups (except root) + for _, g := range createdGroups { + if g.Name != "root" { + helpers.DeleteGroup(t, client, accessToken, g.ID) + } + } + }) +} diff --git a/infra/test_script/user-registration/manual_creation_test.go b/infra/test_script/user-registration/manual_creation_test.go new file mode 100644 index 0000000..e70fe69 --- /dev/null +++ b/infra/test_script/user-registration/manual_creation_test.go @@ -0,0 +1,116 @@ +package userregistration + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + helpers "github.com/jekiapp/topic-master/infra/test_script/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManualUserCreation(t *testing.T) { + client := &http.Client{} + accessToken := helpers.LoginAsRoot(t, client, helpers.GetHost()) + suffix := "3424" + group := helpers.CreateGroup( + t, + client, + helpers.GetHost(), + accessToken, + "engineering-user-"+suffix, + "Engineering Team for manual creation test", + ) + + // Step 2: Create a user with a generated password + username := "bob-" + suffix + name := "Bob Marley" + initialPassword := "bobpass" + role := "member" + u, err := helpers.CreateUser( + client, + accessToken, + username, + name, + initialPassword, + []helpers.GroupsReq{{GroupID: group.ID, Role: role}}, + ) + require.NoError(t, err) + require.Equal(t, username, u.Username) + + // Step 3: Try to login with the generated password (should require password change) + loginResp, _ := helpers.LoginUser(t, client, username, initialPassword) + defer loginResp.Body.Close() + body, _ := io.ReadAll(loginResp.Body) + var loginResult map[string]interface{} + _ = json.Unmarshal(body, &loginResult) + + var token string + if redirect, ok := loginResult["redirect"].(string); ok { + // Extract token from redirect URL + const prefix = "/reset-password?token=" + if idx := bytes.Index([]byte(redirect), []byte(prefix)); idx != -1 { + token = redirect[idx+len(prefix):] + } + } + if token == "" { + t.Fatalf("expected redirect with token in login response, got: %v", loginResult) + } + + // Step 4: GET to /api/user/reset-password with token to validate and get username + getURL := helpers.GetHost() + "/api/user/reset-password?token=" + token + getResp, err := client.Get(getURL) + require.NoError(t, err) + defer getResp.Body.Close() + getRespBody, _ := io.ReadAll(getResp.Body) + if !assert.Equal(t, http.StatusOK, getResp.StatusCode) { + fmt.Println(string(getRespBody)) + } + var getResult struct { + Data struct { + Username string `json:"username"` + } `json:"data"` + } + _ = json.Unmarshal(getRespBody, &getResult) + if getResult.Data.Username != username { + t.Fatalf("expected username in reset GET, got: %v", getResult) + } + + // Step 5: Reset the password using the reset-password endpoint + newPassword := "bobnewpass" + resetReq := map[string]string{ + "token": token, + "new_password": newPassword, + "confirm_password": newPassword, + } + resetBody, _ := json.Marshal(resetReq) + resetReqObj, _ := http.NewRequest("POST", helpers.GetHost()+"/api/user/reset-password", bytes.NewReader(resetBody)) + resetReqObj.Header.Set("Content-Type", "application/json") + resetResp, err := client.Do(resetReqObj) + require.NoError(t, err) + defer resetResp.Body.Close() + resetRespBody, _ := io.ReadAll(resetResp.Body) + if !assert.Equal(t, http.StatusOK, resetResp.StatusCode) { + fmt.Println(string(resetRespBody)) + } + var resetResult struct { + Status string `json:"status"` + } + _ = json.Unmarshal(resetRespBody, &resetResult) + if resetResult.Status != "success" { + t.Fatalf("expected password reset success, got: %v body: %s", resetResult, string(resetRespBody)) + } + + // Step 6: Login with the new password (should succeed) + loginResp2, _ := helpers.LoginUser(t, client, username, newPassword) + defer loginResp2.Body.Close() + require.Equal(t, http.StatusOK, loginResp2.StatusCode) + + // clean up + helpers.DeleteUser(t, client, accessToken, u.ID) + helpers.DeleteGroup(t, client, accessToken, group.ID) +} diff --git a/infra/test_script/user-registration/signup_test.go b/infra/test_script/user-registration/signup_test.go new file mode 100644 index 0000000..53c1564 --- /dev/null +++ b/infra/test_script/user-registration/signup_test.go @@ -0,0 +1,184 @@ +package userregistration + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + helpers "github.com/jekiapp/topic-master/infra/test_script/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSignupAndViewApplication(t *testing.T) { + client := &http.Client{} + accessToken := helpers.LoginAsRoot(t, client, helpers.GetHost()) + suffix := "8687" + group := helpers.CreateGroup( + t, + client, + helpers.GetHost(), + accessToken, + "engineering-signup-"+suffix, + "Engineering Team for signup test", + ) + + var applicationID string + + t.Run("signup", func(t *testing.T) { + signupReq := map[string]interface{}{ + "username": "alice-" + suffix, + "name": "Alice Smith", + "password": "alicepass", + "confirm_password": "alicepass", + "reason": "I want to join engineering", + "group_id": group.ID, + "group_name": group.Name, + "group_role": "member", + } + body, _ := json.Marshal(signupReq) + resp, err := client.Post(helpers.GetHost()+"/api/signup", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var signupResp struct { + Data struct { + ApplicationID string `json:"application_id"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&signupResp) + require.NoError(t, err) + require.NotEmpty(t, signupResp.Data.ApplicationID, "application_id should be present") + applicationID = signupResp.Data.ApplicationID + }) + + aliceID := "" + t.Run("check detail", func(t *testing.T) { + require.NotEmpty(t, applicationID, "application_id should be set from signup subtest") + appDetailReq, _ := http.NewRequest("GET", helpers.GetHost()+"/api/signup/app?id="+applicationID, nil) + appDetailResp, err := client.Do(appDetailReq) + require.NoError(t, err) + defer appDetailResp.Body.Close() + require.Equal(t, http.StatusOK, appDetailResp.StatusCode) + var appDetail struct { + Data struct { + Application struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Status string `json:"status"` + Reason string `json:"reason"` + Type string `json:"type"` + } `json:"application"` + User struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"user"` + } `json:"data"` + } + bodyBytes, _ := io.ReadAll(appDetailResp.Body) + err = json.Unmarshal(bodyBytes, &appDetail) + require.NoError(t, err, "failed to decode signup app detail: %s", string(bodyBytes)) + require.Equal(t, applicationID, appDetail.Data.Application.ID) + require.Equal(t, "alice-"+suffix, appDetail.Data.User.Username) + require.Equal(t, "Alice Smith", appDetail.Data.User.Name) + aliceID = appDetail.Data.User.ID + }) + + t.Run("root assignment list", func(t *testing.T) { + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/tickets/list-my-assignment", nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var assignmentResp struct { + Data struct { + Applications []struct { + ID string `json:"id"` + ApplicantName string `json:"applicant_name"` + } `json:"applications"` + HasNext bool `json:"has_next"` + } `json:"data"` + } + body, _ := io.ReadAll(resp.Body) + err = json.Unmarshal(body, &assignmentResp) + require.NoError(t, err, "failed to decode assignment list: %s", string(body)) + found := false + for _, app := range assignmentResp.Data.Applications { + if app.ID == applicationID { + found = true + break + } + } + require.True(t, found, "application_id %s should be present in root's assignment list", applicationID) + }) + + t.Run("root open application detail", func(t *testing.T) { + req, _ := http.NewRequest("GET", helpers.GetHost()+"/api/tickets/detail?id="+applicationID, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var detailResp struct { + Data struct { + Ticket struct { + ID string `json:"id"` + } `json:"ticket"` + Applicant struct { + Username string `json:"username"` + Name string `json:"name"` + } `json:"applicant"` + } `json:"data"` + } + body, _ := io.ReadAll(resp.Body) + err = json.Unmarshal(body, &detailResp) + require.NoError(t, err, "failed to decode ticket detail: %s", string(body)) + require.Equal(t, applicationID, detailResp.Data.Ticket.ID) + require.Equal(t, "alice-"+suffix, detailResp.Data.Applicant.Username) + require.Equal(t, "Alice Smith", detailResp.Data.Applicant.Name) + }) + + t.Run("root approve application", func(t *testing.T) { + approveReq := map[string]interface{}{ + "action": "approve", + "application_id": applicationID, + } + body, _ := json.Marshal(approveReq) + req, _ := http.NewRequest("POST", helpers.GetHost()+"/api/tickets/action", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + var approveResp struct { + Data struct { + Status string `json:"status"` + Message string `json:"message"` + } `json:"data"` + } + respBody, _ := io.ReadAll(resp.Body) + err = json.Unmarshal(respBody, &approveResp) + require.NoError(t, err, "failed to decode approve response: %s", string(respBody)) + require.Equal(t, "success", approveResp.Data.Status) + }) + + t.Run("user can login after approval", func(t *testing.T) { + loginResp, _ := helpers.LoginUser(t, client, "alice-"+suffix, "alicepass") + defer loginResp.Body.Close() + if !assert.Equal(t, http.StatusOK, loginResp.StatusCode) { + body, _ := io.ReadAll(loginResp.Body) + fmt.Println(string(body)) + } + }) + + t.Run("cleanup", func(t *testing.T) { + helpers.DeleteUser(t, client, accessToken, aliceID) + helpers.DeleteGroup(t, client, accessToken, group.ID) + }) +} diff --git a/infra/test_setup/docker-compose.override.yml b/infra/test_setup/docker-compose.override.yml new file mode 100644 index 0000000..8336bd1 --- /dev/null +++ b/infra/test_setup/docker-compose.override.yml @@ -0,0 +1,7 @@ +services: + nsqlookupd: + volumes: + - ./nsq/data:/data + nsqd: + volumes: + - ./nsq/data:/data \ No newline at end of file diff --git a/infra/test_setup/docker-compose.yml b/infra/test_setup/docker-compose.yml index 0332f80..145d6fa 100644 --- a/infra/test_setup/docker-compose.yml +++ b/infra/test_setup/docker-compose.yml @@ -7,6 +7,8 @@ services: - "4160:4160" # TCP interface volumes: - nsq_data:/data + networks: + - topic_master_network nsqd: image: nsqio/nsq:latest @@ -19,6 +21,8 @@ services: - nsq_data:/data depends_on: - nsqlookupd + networks: + - topic_master_network nsqadmin: image: nsqio/nsq:latest @@ -28,6 +32,8 @@ services: depends_on: - nsqlookupd - nsqd + networks: + - topic_master_network publisher: build: @@ -37,6 +43,8 @@ services: - ./topics.txt:/app/topics.txt depends_on: - nsqd + networks: + - topic_master_network consumer: build: @@ -51,11 +59,12 @@ services: - nsqd - nsqlookupd - publisher + networks: + - topic_master_network volumes: nsq_data: - driver: local - driver_opts: - type: none - o: bind - device: ${GOPATH}/src/github.com/jekiapp/topic-master/infra/test_setup/data \ No newline at end of file + +networks: + topic_master_network: + external: true \ No newline at end of file diff --git a/internal/config/root.go b/internal/config/root.go index d620210..aa1eeac 100644 --- a/internal/config/root.go +++ b/internal/config/root.go @@ -30,22 +30,30 @@ func CheckAndSetupRoot(db *buntdb.DB) error { fmt.Println("Root group or root user not found. Setting up...") var password string - for { - fmt.Print("Set password for root user: ") - bytePassword, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println("") // for newline after password input - if err != nil { - return errors.New("failed to read password: " + err.Error()) - } - password = strings.TrimSpace(string(bytePassword)) + // Check environment variable first + password = strings.TrimSpace(os.Getenv("TOPIC_MASTER_ROOT_PASS")) + if password != "" { if len(password) < aclmodel.MinPasswordLength { - fmt.Println("Password must be at least " + strconv.Itoa(aclmodel.MinPasswordLength) + " characters. Please try again.") - continue + return fmt.Errorf("Password from TOPIC_MASTER_ROOT_PASS must be at least %d characters", aclmodel.MinPasswordLength) + } + fmt.Println("Using root password from environment variable TOPIC_MASTER_ROOT_PASS.") + } else { + for { + fmt.Print("Set password for root user: ") + bytePassword, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println("") // for newline after password input + if err != nil { + return errors.New("failed to read password: " + err.Error()) + } + password = strings.TrimSpace(string(bytePassword)) + if len(password) < aclmodel.MinPasswordLength { + fmt.Println("Password must be at least " + strconv.Itoa(aclmodel.MinPasswordLength) + " characters. Please try again.") + continue + } + break } - break + fmt.Println("Password set successfully.") } - - fmt.Println("Password set successfully.") fmt.Println("Now you can login using username: root") now := time.Now() diff --git a/internal/model/acl/permission.go b/internal/model/acl/permission.go index 01f103c..de45940 100644 --- a/internal/model/acl/permission.go +++ b/internal/model/acl/permission.go @@ -35,6 +35,10 @@ var ( Name: "signup", Description: "Signup a user", } + Permission_Entity_Desc_Update = Permission{ + Name: "entity:desc:update", + Description: "Update the description of an entity", + } ) var PermissionList = map[string]Permission{ diff --git a/internal/usecase/entity/claim_entity.go b/internal/usecase/entity/claim_entity.go index 5ca9c08..0ccd0a7 100644 --- a/internal/usecase/entity/claim_entity.go +++ b/internal/usecase/entity/claim_entity.go @@ -109,17 +109,17 @@ func (uc ClaimEntityUsecase) Handle(ctx context.Context, req ClaimEntityRequest) } group, err := uc.repo.GetGroupByName(req.GroupName) if err != nil { - return ClaimEntityResponse{}, errors.New("group not found") + return ClaimEntityResponse{}, fmt.Errorf("group %s not found", req.GroupName) } // Validate user is a member of the group _, err = uc.repo.GetUserGroup(user.ID, group.ID) if err != nil { - return ClaimEntityResponse{}, errors.New("user is not a member of the group") + return ClaimEntityResponse{}, fmt.Errorf("user %s is not a member of the group %s", user.ID, req.GroupName) } // get entity by id , then use the entity name as the title entityObj, err := uc.repo.GetEntityByID(req.EntityID) if err != nil { - return ClaimEntityResponse{}, errors.New("entity not found") + return ClaimEntityResponse{}, fmt.Errorf("entity %s not found", req.EntityID) } groupOwnerID := group.ID diff --git a/internal/usecase/web/static/topic-details/topic_details.js b/internal/usecase/web/static/topic-details/topic_details.js index 4346099..3269c5e 100644 --- a/internal/usecase/web/static/topic-details/topic_details.js +++ b/internal/usecase/web/static/topic-details/topic_details.js @@ -52,7 +52,7 @@ $(function() { $check.on('click', function() { var newValue = $eventTrigger.val(); $.ajax({ - url: '/api/entity/update-description', + url: '/api/entity/update-description?entity_id=' + detail.id, method: 'POST', contentType: 'application/json', data: JSON.stringify({ entity_id: detail.id, description: newValue }), diff --git a/pkg/util/net.go b/pkg/util/net.go index 54565c6..e31e9c5 100644 --- a/pkg/util/net.go +++ b/pkg/util/net.go @@ -2,36 +2,20 @@ package util import ( "net" - "strings" + "os" ) -// ReplaceDockerHostWithLocalhost replaces any non-IP host in the input slice with 127.0.0.1, keeping the port. -func ReplaceDockerIPWithLocalhost(ip string) string { +// ReplaceDockerIPWithLocalhost is a helper function to replace the docker IP +// with the localhost IP when detected to run in local environment. +func ReplaceDockerIPWithLocalhost(address string) string { + if os.Getenv("IN_LOCAL") == "" { + return address + } // Try to split host and port using net.SplitHostPort, which handles IPv6 - host, port, err := net.SplitHostPort(ip) + _, port, err := net.SplitHostPort(address) if err != nil { // If error, it might be because there's no port, so treat the whole as host - host = ip port = "" } - - // Remove brackets for IPv6 if present - trimmedHost := strings.Trim(host, "[]") - - if net.ParseIP(trimmedHost) == nil { - if port != "" { - return "127.0.0.1:" + port - } else { - return "127.0.0.1" - } - } else { - if port != "" { - // Reconstruct with brackets for IPv6 if needed - if strings.Contains(trimmedHost, ":") { - return "[" + trimmedHost + "]:" + port - } - return trimmedHost + ":" + port - } - return trimmedHost - } + return "127.0.0.1:" + port }