diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..18751e5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,51 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./main" + cmd = "go build -o ./main ./cmd/api/main.go" + delay = 1000 + exclude_dir = ["tmp", "vendor", "cmd/scripts"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = true + keep_scroll = true \ No newline at end of file diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a9a9e45..5a2fbbd 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -5,14 +5,17 @@ WORKDIR /app ARG GO_VERSION ARG GOLANGCI_LINT_VERSION ARG MIGRATE_VERSION +ARG AIR_VERSION RUN apt update &&\ apt install git wget curl -y &&\ wget https://go.dev/dl/go$GO_VERSION.linux-amd64.tar.gz &&\ rm -rf /usr/local/go && tar -C /usr/local -xzf go$GO_VERSION.linux-amd64.tar.gz &&\ - curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b /usr/local/go/bin &&\ wget https://github.com/golangci/golangci-lint/releases/download/v$GOLANGCI_LINT_VERSION/golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.deb &&\ dpkg -i golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.deb &&\ wget https://github.com/golang-migrate/migrate/releases/download/v$MIGRATE_VERSION/migrate.linux-amd64.deb &&\ dpkg -i migrate.linux-amd64.deb &&\ + wget https://github.com/air-verse/air/releases/download/v$AIR_VERSION/air_${AIR_VERSION}_linux_amd64 &&\ + chmod +x air_${AIR_VERSION}_linux_amd64 &&\ + mv air_${AIR_VERSION}_linux_amd64 /usr/local/go/bin/air &&\ echo "export PATH=$PATH:/usr/local/go/bin" > /root/.bashrc \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b95c93c..150ba77 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,9 +4,10 @@ "build": { "dockerfile": "Dockerfile", "args": { - "GO_VERSION": "1.24.0", - "GOLANGCI_LINT_VERSION": "1.64.5", - "MIGRATE_VERSION": "4.18.2" + "GO_VERSION": "1.24.2", + "GOLANGCI_LINT_VERSION": "2.1.2", + "MIGRATE_VERSION": "4.18.2", + "AIR_VERSION": "1.61.7" } }, "customizations": { @@ -16,6 +17,10 @@ ] } }, + "runArgs": [ + "--network=dev-network", + "--name=trepa-dev" + ], "forwardPorts": [ 3000 ] diff --git a/.gitignore b/.gitignore index c759515..e99ee53 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ go.work.sum .env # dist directory -dist/ \ No newline at end of file +dist/ + +tmp/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 017bdb7..a8f525a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,15 @@ "editor.formatOnSave": true, "go.lintTool": "golangci-lint", "go.lintFlags": [ - "--fast" + "--path-mode=abs", + "--fast-only" + ], + "go.formatTool": "custom", + "go.alternateTools": { + "customFormatter": "golangci-lint" + }, + "go.formatFlags": [ + "fmt", + "--stdin" ] } \ No newline at end of file diff --git a/bruno/accounts/folder.bru b/bruno/accounts/folder.bru new file mode 100644 index 0000000..c054647 --- /dev/null +++ b/bruno/accounts/folder.bru @@ -0,0 +1,3 @@ +meta { + name: accounts +} diff --git a/bruno/accounts/login.bru b/bruno/accounts/login.bru new file mode 100644 index 0000000..e123bf1 --- /dev/null +++ b/bruno/accounts/login.bru @@ -0,0 +1,18 @@ +meta { + name: login + type: http + seq: 1 +} + +post { + url: {{base_url}}/accounts/login + body: json + auth: inherit +} + +body:json { + { + "username": "lol", + "password": "lol" + } +} diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..233a852 --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "trepa", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/bruno/environments/dev.bru b/bruno/environments/dev.bru new file mode 100644 index 0000000..82175bb --- /dev/null +++ b/bruno/environments/dev.bru @@ -0,0 +1,3 @@ +vars { + base_url: 127.0.0.1:3000 +} diff --git a/bruno/environments/test.bru b/bruno/environments/test.bru new file mode 100644 index 0000000..e8e40ba --- /dev/null +++ b/bruno/environments/test.bru @@ -0,0 +1,3 @@ +vars { + base_url: {{process.env.BASE_URL}} +} diff --git a/.woodpecker/.build.yml b/build/ci/.build.yml similarity index 83% rename from .woodpecker/.build.yml rename to build/ci/.build.yml index 860c71f..e4809fe 100644 --- a/.woodpecker/.build.yml +++ b/build/ci/.build.yml @@ -3,7 +3,7 @@ when: steps: - name: build - image: golang:1.24-alpine + image: golang:1.24.2-alpine commands: - apk update - apk add bash diff --git a/.woodpecker/.lint.yml b/build/ci/.lint.yml similarity index 67% rename from .woodpecker/.lint.yml rename to build/ci/.lint.yml index 5ff810f..eb88550 100644 --- a/.woodpecker/.lint.yml +++ b/build/ci/.lint.yml @@ -3,9 +3,9 @@ when: steps: - name: lint - image: golang:1.24-alpine + image: golang:1.24.2-alpine commands: - apk update - apk add bash curl jq - - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.64.5 - - $(go env GOPATH)/bin/golangci-lint run --fast + - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.2 + - $(go env GOPATH)/bin/golangci-lint run --fast-only --path-mode=abs diff --git a/.woodpecker/.publish.yml b/build/ci/.publish.yml similarity index 100% rename from .woodpecker/.publish.yml rename to build/ci/.publish.yml diff --git a/.woodpecker/.tests.yml b/build/ci/.tests.yml similarity index 93% rename from .woodpecker/.tests.yml rename to build/ci/.tests.yml index 61d8312..eea6ba2 100644 --- a/.woodpecker/.tests.yml +++ b/build/ci/.tests.yml @@ -3,7 +3,7 @@ when: steps: - name: tests - image: golang:1.24-alpine + image: golang:1.24.2-alpine environment: TEST_DB_HOST: db TEST_DB_PORT: 5432 diff --git a/Dockerfile b/build/docker/Dockerfile similarity index 100% rename from Dockerfile rename to build/docker/Dockerfile diff --git a/cmd/api/main.go b/cmd/api/main.go index c3545bc..0a89ccb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -11,7 +11,7 @@ import ( "syscall" "time" - "gitea.qpismont.fr/qpismont/trepa/internal/accounts" + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/api" "gitea.qpismont.fr/qpismont/trepa/internal/core" "github.com/jmoiron/sqlx" ) @@ -105,7 +105,7 @@ func setupDB() *sqlx.DB { func setupRouter(db *sqlx.DB) *core.ServerMux { router := core.NewServerMux() - accounts.BindRoutes(router, db) + api.BindRoutes(router, db) return router } diff --git a/go.mod b/go.mod index 4da147d..4411c7c 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,33 @@ module gitea.qpismont.fr/qpismont/trepa -go 1.24.0 +go 1.24.2 require ( - github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/go-playground/validator/v10 v10.26.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx v3.6.2+incompatible github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 - github.com/magiconair/properties v1.8.9 + github.com/matthewhartstonge/argon2 v1.2.0 github.com/stretchr/testify v1.10.0 ) require ( github.com/cockroachdb/apd v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/text v0.22.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index dee274e..e16b052 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,26 @@ github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 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/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-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= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= +github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc= github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o= @@ -18,10 +32,12 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= -github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/matthewhartstonge/argon2 v1.2.0 h1:oHo0H92JcmG4q5Ax6MuwDHa6iuJPz97RLwSfqcrjsSY= +github.com/matthewhartstonge/argon2 v1.2.0/go.mod h1:2zMl2u3Ooe9zkpeU61cmcAJ4vgMC3YfvRbKWnPg0wAU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -30,12 +46,24 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -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/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +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/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 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= diff --git a/internal/accounts/api/controller.go b/internal/accounts/api/controller.go new file mode 100644 index 0000000..b2682d0 --- /dev/null +++ b/internal/accounts/api/controller.go @@ -0,0 +1,90 @@ +package api + +import ( + "encoding/json" + "net/http" + + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/domain" + "gitea.qpismont.fr/qpismont/trepa/internal/core" + "github.com/go-playground/validator/v10" +) + +var validate *validator.Validate + +type Controller struct { + service domain.AccountService +} + +func NewController(service domain.AccountService) Controller { + validate = validator.New() + + return Controller{service: service} +} + +func (c Controller) GetAccount(w *core.Response, r *http.Request) { + claims := r.Context().Value("claims").(core.JWTClaims) + + account, httpErr := c.service.GetAccount(claims.AccountId) + if httpErr != nil { + w.WriteError(httpErr) + return + } + + response := GetAccountResponse{ + Id: account.Id, + Username: account.Username, + RoleId: account.RoleId, + CreatedAt: account.CreatedAt, + UpdatedAt: account.UpdatedAt, + } + + responseJson, err := json.Marshal(response) + if err != nil { + w.WriteError(core.NewInternalServerError(err)) + return + } + + w.Json(responseJson) +} + +func (c Controller) Login(w *core.Response, r *http.Request) { + var request LoginAccountRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + w.WriteError(core.ErrInvalidStruct) + return + } + + if err := validate.Struct(request); err != nil { + w.WriteError(core.ErrInvalidStruct) + return + } + + result, httpErr := c.service.Login(domain.AccountLogin{ + Username: request.Username, + Password: request.Password, + }) + + if httpErr != nil { + w.WriteError(httpErr) + return + } + + response := LoginAccountResponse{ + Account: Account{ + Id: result.Account.Id, + Username: result.Account.Username, + RoleId: result.Account.RoleId, + CreatedAt: result.Account.CreatedAt, + UpdatedAt: result.Account.UpdatedAt, + }, + Token: result.Token, + } + + responseJson, err := json.Marshal(response) + if err != nil { + w.WriteError(core.NewInternalServerError(err)) + return + } + + w.Json(responseJson) +} diff --git a/internal/accounts/api/dto.go b/internal/accounts/api/dto.go new file mode 100644 index 0000000..71d5ae4 --- /dev/null +++ b/internal/accounts/api/dto.go @@ -0,0 +1,27 @@ +package api + +type LoginAccountRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type LoginAccountResponse struct { + Account Account `json:"account"` + Token string `json:"token"` +} + +type Account struct { + Id int `json:"id"` + Username string `json:"username"` + RoleId int `json:"role_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type GetAccountResponse struct { + Id int `json:"id"` + Username string `json:"username"` + RoleId int `json:"role_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/accounts/api/routes.go b/internal/accounts/api/routes.go new file mode 100644 index 0000000..6f2e76f --- /dev/null +++ b/internal/accounts/api/routes.go @@ -0,0 +1,17 @@ +package api + +import ( + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/repository" + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/service" + "gitea.qpismont.fr/qpismont/trepa/internal/core" + "github.com/jmoiron/sqlx" +) + +func BindRoutes(srv *core.ServerMux, db *sqlx.DB) { + repository := repository.NewRepository(db) + service := service.NewService(repository) + controller := NewController(service) + + srv.HandleFunc("POST /accounts/login", controller.Login) + srv.HandleFunc("GET /accounts/me", core.JwtMiddleware(controller.GetAccount)) +} diff --git a/internal/accounts/controller.go b/internal/accounts/controller.go deleted file mode 100644 index a807d79..0000000 --- a/internal/accounts/controller.go +++ /dev/null @@ -1,20 +0,0 @@ -package accounts - -import ( - "net/http" - - "gitea.qpismont.fr/qpismont/trepa/internal/core" -) - -type Controller struct { - service Service -} - -func NewController(service Service) Controller { - return Controller{service: service} -} - -func (c *Controller) CreateAccount(w *core.Response, r *http.Request) { - w.WriteHeader(http.StatusCreated) - w.Json([]byte("test")) -} diff --git a/internal/accounts/model.go b/internal/accounts/domain/account.go similarity index 50% rename from internal/accounts/model.go rename to internal/accounts/domain/account.go index fad9d86..38ab74e 100644 --- a/internal/accounts/model.go +++ b/internal/accounts/domain/account.go @@ -1,4 +1,4 @@ -package accounts +package domain type Account struct { Id int `db:"id" json:"id"` @@ -8,3 +8,25 @@ type Account struct { CreatedAt string `db:"created_at" json:"created_at"` UpdatedAt string `db:"updated_at" json:"updated_at"` } + +type AccountLogin struct { + Username string + Password string +} + +type AccountWithToken struct { + Account *Account + Token string +} + +type AccountCreate struct { + Username string + Password string + RoleId int +} + +type AccountRegister struct { + Username string + Password string + RoleId int +} diff --git a/internal/accounts/errors.go b/internal/accounts/domain/errors.go similarity index 95% rename from internal/accounts/errors.go rename to internal/accounts/domain/errors.go index 2f1428c..2df998b 100644 --- a/internal/accounts/errors.go +++ b/internal/accounts/domain/errors.go @@ -1,4 +1,4 @@ -package accounts +package domain import ( "net/http" diff --git a/internal/accounts/domain/repository.go b/internal/accounts/domain/repository.go new file mode 100644 index 0000000..fc3e6cc --- /dev/null +++ b/internal/accounts/domain/repository.go @@ -0,0 +1,7 @@ +package domain + +type AccountRepository interface { + Insert(account Account) (int, error) + FetchOneByUsername(username string) (*Account, error) + FetchOneById(id int) (*Account, error) +} diff --git a/internal/accounts/domain/service.go b/internal/accounts/domain/service.go new file mode 100644 index 0000000..f73b455 --- /dev/null +++ b/internal/accounts/domain/service.go @@ -0,0 +1,9 @@ +package domain + +import "gitea.qpismont.fr/qpismont/trepa/internal/core" + +type AccountService interface { + Login(login AccountLogin) (*AccountWithToken, *core.HTTPError) + Register(register AccountRegister) (int, *core.HTTPError) + GetAccount(id int) (*Account, *core.HTTPError) +} diff --git a/internal/accounts/repository.go b/internal/accounts/repository.go deleted file mode 100644 index 59da0f5..0000000 --- a/internal/accounts/repository.go +++ /dev/null @@ -1,48 +0,0 @@ -package accounts - -import ( - "database/sql" - - "github.com/jmoiron/sqlx" -) - -type Repository struct { - db *sqlx.DB -} - -func NewRepository(db *sqlx.DB) Repository { - return Repository{db: db} -} - -func (r *Repository) Insert(account *Account) (int, error) { - var id int - - stmt, err := r.db.Prepare(SqlInsert) - if err != nil { - return id, err - } - - defer stmt.Close() - - err = stmt.QueryRow(account.Username, account.Password, account.RoleId).Scan(&id) - if err != nil { - return id, err - } - - return id, nil -} - -func (r *Repository) FetchOneByUsername(username string) (*Account, error) { - var account Account - - err := r.db.Get(&account, SqlFetchOneByUsername, username) - if err != nil { - if err == sql.ErrNoRows { - return nil, nil - } else { - return nil, err - } - } - - return &account, nil -} diff --git a/internal/accounts/repository/account.go b/internal/accounts/repository/account.go new file mode 100644 index 0000000..b959d72 --- /dev/null +++ b/internal/accounts/repository/account.go @@ -0,0 +1,64 @@ +package repository + +import ( + "database/sql" + + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/domain" + "github.com/jmoiron/sqlx" +) + +type Repository struct { + db *sqlx.DB +} + +func NewRepository(db *sqlx.DB) domain.AccountRepository { + return &Repository{db: db} +} + +func (r *Repository) Insert(account domain.Account) (int, error) { + var id int + + stmt, err := r.db.Prepare(SqlInsert) + if err != nil { + return id, err + } + + defer stmt.Close() + + err = stmt.QueryRow(account.Username, account.Password, account.RoleId).Scan(&id) + if err != nil { + return id, err + } + + return id, nil +} + +func (r *Repository) FetchOneById(id int) (*domain.Account, error) { + var account domain.Account + + err := r.db.Get(&account, SqlFetchOneById, id) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } else { + return nil, err + } + } + + return &account, nil +} + +func (r *Repository) FetchOneByUsername(username string) (*domain.Account, error) { + var account domain.Account + + err := r.db.Get(&account, SqlFetchOneByUsername, username) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } else { + return nil, err + } + } + + return &account, nil +} diff --git a/internal/accounts/repository/account_test.go b/internal/accounts/repository/account_test.go new file mode 100644 index 0000000..33db105 --- /dev/null +++ b/internal/accounts/repository/account_test.go @@ -0,0 +1,69 @@ +package repository + +import ( + "testing" + + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/domain" + "gitea.qpismont.fr/qpismont/trepa/test" + "github.com/stretchr/testify/assert" +) + +func TestRepository_Insert(t *testing.T) { + db := test.SetupTestDB(t, "../../..") + defer db.Close() + + repo := NewRepository(db) + + account := domain.Account{ + Username: "test", + Password: "test", + RoleId: 1, + } + + id, err := repo.Insert(account) + if err != nil { + t.Fatalf("Failed to insert account: %v", err) + } + + assert.Equal(t, id, 3) +} + +func TestRepository_FetchOneByUsername(t *testing.T) { + db := test.SetupTestDB(t, "../../..") + defer db.Close() + + repo := NewRepository(db) + + account, err := repo.FetchOneByUsername("admin") + if err != nil { + t.Fatalf("Failed to fetch account: %v", err) + } + + if account == nil { + t.Fatalf("Account not found") + } + + assert.Equal(t, "admin", account.Username) + assert.NotEmpty(t, account.Password) + assert.Equal(t, 1, account.RoleId) +} + +func TestRepository_FetchOneById(t *testing.T) { + db := test.SetupTestDB(t, "../../..") + defer db.Close() + + repo := NewRepository(db) + + account, err := repo.FetchOneById(1) + if err != nil { + t.Fatalf("Failed to fetch account: %v", err) + } + + if account == nil { + t.Fatalf("Account not found") + } + + assert.Equal(t, "admin", account.Username) + assert.NotEmpty(t, account.Password) + assert.Equal(t, 1, account.RoleId) +} diff --git a/internal/accounts/const.go b/internal/accounts/repository/const.go similarity index 69% rename from internal/accounts/const.go rename to internal/accounts/repository/const.go index 6e417dd..a09c820 100644 --- a/internal/accounts/const.go +++ b/internal/accounts/repository/const.go @@ -1,6 +1,7 @@ -package accounts +package repository const ( SqlInsert = "INSERT INTO accounts (username, password, role_id) VALUES ($1, $2, $3) RETURNING id" SqlFetchOneByUsername = "SELECT * FROM accounts WHERE username = $1" + SqlFetchOneById = "SELECT * FROM accounts WHERE id = $1" ) diff --git a/internal/accounts/repository_test.go b/internal/accounts/repository_test.go deleted file mode 100644 index 552f6b1..0000000 --- a/internal/accounts/repository_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package accounts - -import ( - "testing" - - "gitea.qpismont.fr/qpismont/trepa/test" - "github.com/magiconair/properties/assert" -) - -func TestRepository_Insert(t *testing.T) { - db := test.SetupTestDB(t, "../..") - defer db.Close() - - repo := NewRepository(db) - - account := &Account{ - Username: "test", - Password: "test", - RoleId: 1, - } - - id, err := repo.Insert(account) - if err != nil { - t.Fatalf("Failed to insert account: %v", err) - } - - assert.Equal(t, id, 3) -} - -func TestRepository_FetchOneByUsername(t *testing.T) { - db := test.SetupTestDB(t, "../..") - defer db.Close() - - repo := NewRepository(db) - - account, err := repo.FetchOneByUsername("admin") - if err != nil { - t.Fatalf("Failed to fetch account: %v", err) - } - - if account == nil { - t.Fatalf("Account not found") - } - - assert.Equal(t, account.Username, "admin") - assert.Equal(t, account.Password, "LOLPASSWORD") - assert.Equal(t, account.RoleId, 1) -} diff --git a/internal/accounts/routes.go b/internal/accounts/routes.go deleted file mode 100644 index 01b8553..0000000 --- a/internal/accounts/routes.go +++ /dev/null @@ -1,14 +0,0 @@ -package accounts - -import ( - "gitea.qpismont.fr/qpismont/trepa/internal/core" - "github.com/jmoiron/sqlx" -) - -func BindRoutes(srv *core.ServerMux, db *sqlx.DB) { - repository := NewRepository(db) - service := NewService(repository) - controller := NewController(service) - - srv.HandleFunc("GET /accounts", controller.CreateAccount) -} diff --git a/internal/accounts/service.go b/internal/accounts/service.go deleted file mode 100644 index 87f47c5..0000000 --- a/internal/accounts/service.go +++ /dev/null @@ -1,9 +0,0 @@ -package accounts - -type Service struct { - repository Repository -} - -func NewService(repository Repository) Service { - return Service{repository: repository} -} diff --git a/internal/accounts/service/account.go b/internal/accounts/service/account.go new file mode 100644 index 0000000..b708432 --- /dev/null +++ b/internal/accounts/service/account.go @@ -0,0 +1,91 @@ +package service + +import ( + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/domain" + "gitea.qpismont.fr/qpismont/trepa/internal/core" +) + +type Service struct { + repository domain.AccountRepository +} + +func NewService(repository domain.AccountRepository) domain.AccountService { + return &Service{repository: repository} +} + +func (s *Service) GetAccount(id int) (*domain.Account, *core.HTTPError) { + account, err := s.repository.FetchOneById(id) + if err != nil { + return nil, core.NewInternalServerError(err) + } + + if account == nil { + return nil, domain.ErrAccountNotFound + } + + return account, nil +} + +func (s *Service) Login(login domain.AccountLogin) (*domain.AccountWithToken, *core.HTTPError) { + account, err := s.repository.FetchOneByUsername(login.Username) + if err != nil { + return nil, core.NewInternalServerError(err) + } + + if account == nil { + return nil, domain.ErrAccountNotFound + } + + ok, err := core.ComparePassword(login.Password, account.Password) + if err != nil { + return nil, core.NewInternalServerError(err) + } + + if !ok { + return nil, domain.ErrBadPassword + } + + claims := core.JWTClaims{ + AccountId: account.Id, + RoleId: account.RoleId, + } + + token, err := core.SignJWT(claims) + if err != nil { + return nil, core.NewInternalServerError(err) + } + + return &domain.AccountWithToken{ + Account: account, + Token: token, + }, nil +} + +func (s *Service) Register(register domain.AccountRegister) (int, *core.HTTPError) { + accountExist, err := s.repository.FetchOneByUsername(register.Username) + if err != nil { + return 0, core.NewInternalServerError(err) + } + + if accountExist != nil { + return 0, domain.ErrAccountAlreadyExists + } + + hashedPassword, err := core.HashPassword(register.Password) + if err != nil { + return 0, core.NewInternalServerError(err) + } + + account := domain.Account{ + Username: register.Username, + Password: hashedPassword, + RoleId: register.RoleId, + } + + id, err := s.repository.Insert(account) + if err != nil { + return 0, core.NewInternalServerError(err) + } + + return id, nil +} diff --git a/internal/accounts/service/account_test.go b/internal/accounts/service/account_test.go new file mode 100644 index 0000000..0903059 --- /dev/null +++ b/internal/accounts/service/account_test.go @@ -0,0 +1,122 @@ +package service + +import ( + "testing" + + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/domain" + "gitea.qpismont.fr/qpismont/trepa/internal/accounts/repository" + "gitea.qpismont.fr/qpismont/trepa/internal/core" + "gitea.qpismont.fr/qpismont/trepa/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockRepository struct { + mock.Mock +} + +func (m *MockRepository) FetchOneById(id int) (*domain.Account, error) { + args := m.Called(id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*domain.Account), args.Error(1) +} + +func (m *MockRepository) FetchOneByUsername(username string) (*domain.Account, error) { + args := m.Called(username) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*domain.Account), args.Error(1) +} + +func (m *MockRepository) Insert(account domain.Account) (int, error) { + args := m.Called(account) + return args.Int(0), args.Error(1) +} + +func (m *MockRepository) FetchOneByRoleId(roleId int) (*domain.Account, error) { + args := m.Called(roleId) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*domain.Account), args.Error(1) +} + +func TestService_Register(t *testing.T) { + mockRepo := new(MockRepository) + + mockRepo.On("FetchOneByUsername", "admin").Return(nil, nil) + mockRepo.On("Insert", mock.Anything).Return(1, nil) + + service := NewService(mockRepo) + + id, _ := service.Register(domain.AccountRegister{ + Username: "admin", + Password: "admin", + RoleId: 1, + }) + + assert.Equal(t, 1, id) + + mockRepo.AssertExpectations(t) +} + +func TestService_GetAccount(t *testing.T) { + db := test.SetupTestDB(t, "../../..") + defer db.Close() + + repo := repository.NewRepository(db) + + service := NewService(repo) + + account, err := service.GetAccount(1) + if err != nil { + t.Fatalf("Failed to get account: %v", err) + } + + if account == nil { + t.Fatalf("Account not found") + } + + assert.Equal(t, "admin", account.Username) + assert.Equal(t, 1, account.RoleId) + assert.NotEmpty(t, account.Password) +} + +func TestService_Login(t *testing.T) { + mockRepo := new(MockRepository) + + testPassword := "testpassword" + hashedPassword, _ := core.HashPassword(testPassword) + + mockRepo.On("FetchOneByUsername", "admin").Return(&domain.Account{ + Id: 1, + Username: "admin", + Password: hashedPassword, + RoleId: 1, + }, nil) + + service := NewService(mockRepo) + + result, err := service.Login(domain.AccountLogin{ + Username: "admin", + Password: testPassword, + }) + + if err != nil { + t.Fatalf("Failed to login: %v", err) + } + + assert.NotNil(t, result) + assert.NotNil(t, result.Token) + assert.NotNil(t, result.Account) + assert.Equal(t, "admin", result.Account.Username) + assert.Equal(t, 1, result.Account.RoleId) + + mockRepo.AssertExpectations(t) +} diff --git a/internal/core/helpers.go b/internal/core/env.go similarity index 100% rename from internal/core/helpers.go rename to internal/core/env.go diff --git a/internal/core/helpers_test.go b/internal/core/env_test.go similarity index 100% rename from internal/core/helpers_test.go rename to internal/core/env_test.go diff --git a/internal/core/errors.go b/internal/core/errors.go index c0ca822..545cccd 100644 --- a/internal/core/errors.go +++ b/internal/core/errors.go @@ -1,11 +1,13 @@ package core import ( + "encoding/json" "net/http" ) var ( - ErrInvalidToken = NewHTTPError(http.StatusUnauthorized, "Invalid token", nil) + ErrInvalidToken = NewHTTPError(http.StatusUnauthorized, "Invalid token", nil) + ErrInvalidStruct = NewHTTPError(http.StatusBadRequest, "Invalid struct", nil) ) type HTTPError struct { @@ -23,6 +25,14 @@ func (e *HTTPError) Unwrap() error { return e.Cause } +func (e *HTTPError) IntoJsonBytes() []byte { + json, err := json.Marshal(e) + if err != nil { + return nil + } + return json +} + func NewHTTPError(code int, message string, cause error) *HTTPError { details := "" if cause != nil { diff --git a/internal/core/hash.go b/internal/core/hash.go new file mode 100644 index 0000000..16a60ae --- /dev/null +++ b/internal/core/hash.go @@ -0,0 +1,29 @@ +package core + +import ( + "github.com/matthewhartstonge/argon2" +) + +func HashPassword(password string) (string, error) { + argon := instanceArgon2() + + hash, err := argon.HashEncoded([]byte(password)) + if err != nil { + return "", err + } + + return string(hash), nil +} + +func ComparePassword(password string, hash string) (bool, error) { + ok, err := argon2.VerifyEncoded([]byte(password), []byte(hash)) + if err != nil { + return false, err + } + + return ok, nil +} + +func instanceArgon2() argon2.Config { + return argon2.DefaultConfig() +} diff --git a/internal/core/hash_test.go b/internal/core/hash_test.go new file mode 100644 index 0000000..4e61e39 --- /dev/null +++ b/internal/core/hash_test.go @@ -0,0 +1,18 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHashPassword(t *testing.T) { + password := "password" + hashedPassword, err := HashPassword(password) + + assert.NoError(t, err) + + hashedOk, err := ComparePassword(password, hashedPassword) + assert.NoError(t, err) + assert.True(t, hashedOk) +} diff --git a/internal/core/http.go b/internal/core/http.go index 23fb068..a4e4aad 100644 --- a/internal/core/http.go +++ b/internal/core/http.go @@ -26,6 +26,12 @@ func (r *Response) WriteHeader(status int) { r.Status = status } +func (r *Response) WriteError(err *HTTPError) { + r.Header().Set("Content-Type", "application/json") + r.WriteHeader(err.Code) + r.Write(err.IntoJsonBytes()) +} + func (r *Response) Json(json []byte) { r.Header().Set("Content-Type", "application/json") r.Write(json) diff --git a/internal/core/jwt.go b/internal/core/jwt.go index 4515334..b1d7b5d 100644 --- a/internal/core/jwt.go +++ b/internal/core/jwt.go @@ -22,7 +22,7 @@ func SignJWT(claims JWTClaims) (string, error) { return token.SignedString([]byte(jwtSecret)) } -func VerifyJWT(token string) (JWTClaims, error) { +func VerifyJWT(token string) (JWTClaims, *HTTPError) { parsedClaims := JWTClaims{} claims, err := jwt.ParseWithClaims(token, &parsedClaims, func(token *jwt.Token) (any, error) { return []byte(jwtSecret), nil diff --git a/internal/core/jwt_test.go b/internal/core/jwt_test.go index d9dce0f..bf48f8a 100644 --- a/internal/core/jwt_test.go +++ b/internal/core/jwt_test.go @@ -19,9 +19,9 @@ func TestJWT_GenerateToken(t *testing.T) { assert.NotEmpty(t, token) - claims, err := VerifyJWT(token) - if err != nil { - t.Fatalf("Failed to verify token: %v", err) + claims, jwtErr := VerifyJWT(token) + if jwtErr != nil { + t.Fatalf("Failed to verify token: %v", jwtErr) } assert.Equal(t, claims.AccountId, 1) diff --git a/internal/core/middleware.go b/internal/core/middleware.go index c3a3db6..5092907 100644 --- a/internal/core/middleware.go +++ b/internal/core/middleware.go @@ -44,3 +44,21 @@ func ErrorMiddleware(next func(w *Response, r *http.Request)) func(w *Response, next(w, r) } } + +func JwtMiddleware(next func(w *Response, r *http.Request)) func(w *Response, r *http.Request) { + return func(w *Response, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + w.WriteError(ErrInvalidToken) + return + } + + _, err := VerifyJWT(token) + if err != nil { + w.WriteError(err) + return + } + + next(w, r) + } +} diff --git a/internal/variables/api/controller.go b/internal/variables/api/controller.go new file mode 100644 index 0000000..bd36d92 --- /dev/null +++ b/internal/variables/api/controller.go @@ -0,0 +1,40 @@ +package api + +import ( + "encoding/json" + "net/http" + + "gitea.qpismont.fr/qpismont/trepa/internal/core" + "gitea.qpismont.fr/qpismont/trepa/internal/variables/domain" +) + +type Controller struct { + service domain.VariableService +} + +func NewController(service domain.VariableService) Controller { + return Controller{service: service} +} + +func (c Controller) GetVariableByName(w *core.Response, r *http.Request) { + name := r.PathValue("name") + + variable, httpErr := c.service.GetVariable(name) + if httpErr != nil { + w.WriteError(httpErr) + return + } + + response := GetVariableResponse{ + Name: variable.Name, + Value: variable.Value, + } + + responseJson, err := json.Marshal(response) + if err != nil { + w.WriteError(core.NewInternalServerError(err)) + return + } + + w.Json(responseJson) +} diff --git a/internal/variables/api/dto.go b/internal/variables/api/dto.go new file mode 100644 index 0000000..241dd7b --- /dev/null +++ b/internal/variables/api/dto.go @@ -0,0 +1,6 @@ +package api + +type GetVariableResponse struct { + Name string `json:"name"` + Value string `json:"value"` +} diff --git a/internal/variables/api/routes.go b/internal/variables/api/routes.go new file mode 100644 index 0000000..b295640 --- /dev/null +++ b/internal/variables/api/routes.go @@ -0,0 +1,16 @@ +package api + +import ( + "gitea.qpismont.fr/qpismont/trepa/internal/core" + "gitea.qpismont.fr/qpismont/trepa/internal/variables/repository" + "gitea.qpismont.fr/qpismont/trepa/internal/variables/service" + "github.com/jmoiron/sqlx" +) + +func BindRoutes(srv *core.ServerMux, db *sqlx.DB) { + repository := repository.NewRepository(db) + service := service.NewService(repository) + controller := NewController(service) + + srv.HandleFunc("GET /variables/{name}", controller.GetVariableByName) +} diff --git a/internal/variables/domain/errors.go b/internal/variables/domain/errors.go new file mode 100644 index 0000000..8833700 --- /dev/null +++ b/internal/variables/domain/errors.go @@ -0,0 +1,9 @@ +package domain + +import ( + "net/http" + + "gitea.qpismont.fr/qpismont/trepa/internal/core" +) + +var ErrVariableNotFound = core.NewHTTPError(http.StatusNotFound, "variable not found", nil) diff --git a/internal/variables/domain/repository.go b/internal/variables/domain/repository.go new file mode 100644 index 0000000..58964ae --- /dev/null +++ b/internal/variables/domain/repository.go @@ -0,0 +1,5 @@ +package domain + +type VariableRepository interface { + FetchOneByName(name string) (*Variable, error) +} diff --git a/internal/variables/domain/service.go b/internal/variables/domain/service.go new file mode 100644 index 0000000..c2fd663 --- /dev/null +++ b/internal/variables/domain/service.go @@ -0,0 +1,7 @@ +package domain + +import "gitea.qpismont.fr/qpismont/trepa/internal/core" + +type VariableService interface { + GetVariable(name string) (*Variable, *core.HTTPError) +} diff --git a/internal/variables/domain/variable.go b/internal/variables/domain/variable.go new file mode 100644 index 0000000..661a9f6 --- /dev/null +++ b/internal/variables/domain/variable.go @@ -0,0 +1,11 @@ +package domain + +import "time" + +type Variable struct { + Id int `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Value string `db:"value" json:"value"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/internal/variables/repository/const.go b/internal/variables/repository/const.go new file mode 100644 index 0000000..9fcbd52 --- /dev/null +++ b/internal/variables/repository/const.go @@ -0,0 +1,5 @@ +package repository + +const ( + SqlFetchOneByName = "SELECT * FROM variables WHERE name = $1" +) diff --git a/internal/variables/repository/variable.go b/internal/variables/repository/variable.go new file mode 100644 index 0000000..68869a8 --- /dev/null +++ b/internal/variables/repository/variable.go @@ -0,0 +1,24 @@ +package repository + +import ( + "gitea.qpismont.fr/qpismont/trepa/internal/variables/domain" + "github.com/jmoiron/sqlx" +) + +type VariableRepository struct { + db *sqlx.DB +} + +func NewRepository(db *sqlx.DB) *VariableRepository { + return &VariableRepository{db: db} +} + +func (r *VariableRepository) FetchOneByName(name string) (*domain.Variable, error) { + var variable domain.Variable + err := r.db.Get(&variable, "SELECT * FROM variables WHERE name = $1", name) + if err != nil { + return nil, err + } + + return &variable, nil +} diff --git a/internal/variables/repository/variable_test.go b/internal/variables/repository/variable_test.go new file mode 100644 index 0000000..d6c4a6c --- /dev/null +++ b/internal/variables/repository/variable_test.go @@ -0,0 +1,20 @@ +package repository + +import ( + "testing" + + "gitea.qpismont.fr/qpismont/trepa/test" + "github.com/stretchr/testify/assert" +) + +func TestVariableRepository_FetchOneByName(t *testing.T) { + db := test.SetupTestDB(t, "../../..") + defer db.Close() + + repository := NewRepository(db) + + variable, err := repository.FetchOneByName("first_account_created") + assert.NoError(t, err) + assert.Equal(t, "first_account_created", variable.Name) + assert.Equal(t, "false", variable.Value) +} diff --git a/internal/variables/service/variable.go b/internal/variables/service/variable.go new file mode 100644 index 0000000..ae4edb0 --- /dev/null +++ b/internal/variables/service/variable.go @@ -0,0 +1,27 @@ +package service + +import ( + "gitea.qpismont.fr/qpismont/trepa/internal/core" + "gitea.qpismont.fr/qpismont/trepa/internal/variables/domain" +) + +type VariableService struct { + repository domain.VariableRepository +} + +func NewService(repository domain.VariableRepository) *VariableService { + return &VariableService{repository: repository} +} + +func (s *VariableService) GetVariable(name string) (*domain.Variable, *core.HTTPError) { + variable, err := s.repository.FetchOneByName(name) + if err != nil { + return nil, core.NewInternalServerError(err) + } + + if variable == nil { + return nil, domain.ErrVariableNotFound + } + + return variable, nil +} diff --git a/internal/variables/service/variable_test.go b/internal/variables/service/variable_test.go new file mode 100644 index 0000000..1ae5bbd --- /dev/null +++ b/internal/variables/service/variable_test.go @@ -0,0 +1,24 @@ +package service + +import ( + "testing" + + "gitea.qpismont.fr/qpismont/trepa/internal/variables/repository" + "gitea.qpismont.fr/qpismont/trepa/test" + "github.com/stretchr/testify/assert" +) + +func TestVariableService_GetVariable(t *testing.T) { + db := test.SetupTestDB(t, "../../..") + defer db.Close() + + repository := repository.NewRepository(db) + service := NewService(repository) + + variable, _ := service.GetVariable("first_account_created") + + assert.NotNil(t, variable) + assert.Equal(t, 1, variable.Id) + assert.Equal(t, "first_account_created", variable.Name) + assert.Equal(t, "false", variable.Value) +} diff --git a/migrations/20250417194859_variables.up.sql b/migrations/20250417194859_variables.up.sql new file mode 100644 index 0000000..43e362d --- /dev/null +++ b/migrations/20250417194859_variables.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE public.variables +( + id integer NOT NULL GENERATED ALWAYS AS IDENTITY, + name text, + value text, + created_at timestamp without time zone NOT NULL DEFAULT now(), + updated_at timestamp without time zone NOT NULL DEFAULT now(), + PRIMARY KEY (id) +); + +ALTER TABLE IF EXISTS public.variables + OWNER to dev; + +INSERT INTO public.variables (name, value) VALUES ('first_account_created', 'false'); \ No newline at end of file diff --git a/scripts/create_migration.sh b/scripts/create_migration.sh new file mode 100644 index 0000000..27fb60d --- /dev/null +++ b/scripts/create_migration.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +timestamp=$(date +%Y%m%d%H%M%S) +migration_file="migrations/$(date +%Y%m%d%H%M%S)_$1.up.sql" +touch $migration_file diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100644 index 0000000..c4c611c --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +if ! command -v migrate &> /dev/null; then + echo "migrate could not be found" + exit 1 +fi + + +migrate -path ./migrations -database "$DATABASE_URL" -verbose up diff --git a/test/fixtures/00-accounts.sql b/test/fixtures/00-accounts.sql index bc38a90..a0ce17f 100644 --- a/test/fixtures/00-accounts.sql +++ b/test/fixtures/00-accounts.sql @@ -1,2 +1,2 @@ -INSERT INTO accounts (username, password, role_id) VALUES ('admin', 'LOLPASSWORD', 1); -INSERT INTO accounts (username, password, role_id) VALUES ('user', 'LOLPASSWORD', 2); +INSERT INTO accounts (username, password, role_id) VALUES ('admin', '##TEST_PASSWORD_HASH##', 1); +INSERT INTO accounts (username, password, role_id) VALUES ('user', '##TEST_PASSWORD_HASH##', 2); diff --git a/test/fixtures/01-variables.sql b/test/fixtures/01-variables.sql new file mode 100644 index 0000000..ec143f8 --- /dev/null +++ b/test/fixtures/01-variables.sql @@ -0,0 +1 @@ +INSERT INTO variables (name, value) VALUES ('first_account_created', 'false'); \ No newline at end of file