end-to-end testing setup

This commit is contained in:
Hayden 2022-09-03 18:42:03 -08:00
parent b4eb7d8ddc
commit ad4c8c9ab4
41 changed files with 544 additions and 313 deletions

4
.gitignore vendored
View file

@ -33,3 +33,7 @@ node_modules
go.work go.work
.task/ .task/
backend/.env backend/.env
# Output Directory for Nuxt/Frontend during build step
backend/app/api/public/*
!backend/app/api/public/.gitkeep

36
Dockerfile Normal file
View file

@ -0,0 +1,36 @@
# Build Nuxt
FROM node:17-alpine as frontend-builder
WORKDIR /app
RUN npm install -g pnpm
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --shamefully-hoist
COPY frontend .
RUN pnpm build
# Build API
FROM golang:alpine AS builder
RUN apk update
RUN apk upgrade
RUN apk add --update git build-base gcc g++
WORKDIR /go/src/app
COPY ./backend .
COPY --from=frontend-builder app/.output go/src/app/app/api/public
RUN go get -d -v ./...
RUN CGO_ENABLED=1 GOOS=linux go build -o /go/bin/api -v ./app/api/*.go
# Production Stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY ./backend/config.template.yml /app/config.yml
COPY --from=builder /go/bin/api /app
RUN chmod +x /app/api
LABEL Name=homebox Version=0.0.1
EXPOSE 7745
WORKDIR /app
CMD [ "./api" ]

View file

@ -31,11 +31,11 @@ tasks:
- cd backend && go test -race -coverprofile=coverage.out -covermode=atomic ./app/... ./internal/... ./pkgs/... -v -cover - cd backend && go test -race -coverprofile=coverage.out -covermode=atomic ./app/... ./internal/... ./pkgs/... -v -cover
silent: true silent: true
client:test: test:integration:
cmds: cmds:
- cd backend && go run ./app/api/ & - cd backend && go run ./app/api/ &
- sleep 5 - sleep 5
- cd client && npm run test:ci - cd frontend && pnpm run test:ci
silent: true silent: true
docker:build: docker:build:

View file

@ -1,23 +0,0 @@
# Build API
FROM golang:alpine AS builder
RUN apk add --no-cache git build-base
WORKDIR /go/src/app
COPY . .
RUN go get -d -v ./...
RUN go build -o /go/bin/api -v ./app/api/*.go
# Production Stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY ./config.template.yml /app/config.yml
COPY --from=builder /go/bin/api /app
RUN chmod +x /app/api
RUN chmod +x /bin/manage
LABEL Name=gowebtemplate Version=0.0.1
EXPOSE 7745
WORKDIR /app
CMD [ "./api" ]

View file

@ -36,7 +36,7 @@ func NewApp(conf *config.Config) *app {
return s return s
} }
func (a *app) StartReoccurringTasks(t time.Duration, fn func()) { func (a *app) StartBgTask(t time.Duration, fn func()) {
for { for {
a.server.Background(fn) a.server.Background(fn)
time.Sleep(t) time.Sleep(t)

View file

@ -1,40 +0,0 @@
package base
import (
"net/http"
"github.com/hay-kot/content/backend/internal/types"
"github.com/hay-kot/content/backend/pkgs/server"
)
type ReadyFunc func() bool
type BaseController struct {
svr *server.Server
}
func NewBaseController(svr *server.Server) *BaseController {
h := &BaseController{
svr: svr,
}
return h
}
// HandleBase godoc
// @Summary Retrieves the basic information about the API
// @Tags Base
// @Produce json
// @Success 200 {object} server.Result{item=types.ApiSummary}
// @Router /status [GET]
func (ctrl *BaseController) HandleBase(ready ReadyFunc, versions ...string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := types.ApiSummary{
Healthy: ready(),
Versions: versions,
Title: "Go API Template",
Message: "Welcome to the Go API Template Application!",
}
server.Respond(w, http.StatusOK, server.Wrap(data))
}
}

View file

@ -1,33 +0,0 @@
package base
import (
"net/http"
"net/http/httptest"
"testing"
)
func GetTestHandler(t *testing.T) *BaseController {
return NewBaseController(nil)
}
func TestHandlersv1_HandleBase(t *testing.T) {
// Setup
hdlrFunc := GetTestHandler(t).HandleBase(func() bool { return true }, "v1")
// Call Handler Func
rr := httptest.NewRecorder()
hdlrFunc(rr, nil)
// Validate Status Code
if rr.Code != http.StatusOK {
t.Errorf("Expected status code to be %d, got %d", http.StatusOK, rr.Code)
}
// Validate Json Payload
expected := `{"item":{"health":true,"versions":["v1"],"title":"Go API Template","message":"Welcome to the Go API Template Application!"}}`
if rr.Body.String() != expected {
t.Errorf("Expected json to be %s, got %s", expected, rr.Body.String())
}
}

View file

@ -21,37 +21,6 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/status": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "Retrieves the basic information about the API",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.ApiSummary"
}
}
}
]
}
}
}
}
},
"/v1/items": { "/v1/items": {
"get": { "get": {
"security": [ "security": [
@ -544,6 +513,25 @@ const docTemplate = `{
} }
} }
}, },
"/v1/status": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "Retrieves the basic information about the API",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/types.ApiSummary"
}
}
}
}
},
"/v1/users/login": { "/v1/users/login": {
"post": { "post": {
"consumes": [ "consumes": [
@ -726,6 +714,25 @@ const docTemplate = `{
} }
} }
} }
},
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Deletes the user account",
"responses": {
"204": {
"description": ""
}
}
} }
}, },
"/v1/users/self/password": { "/v1/users/self/password": {

View file

@ -13,37 +13,6 @@
}, },
"basePath": "/api", "basePath": "/api",
"paths": { "paths": {
"/status": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "Retrieves the basic information about the API",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.ApiSummary"
}
}
}
]
}
}
}
}
},
"/v1/items": { "/v1/items": {
"get": { "get": {
"security": [ "security": [
@ -536,6 +505,25 @@
} }
} }
}, },
"/v1/status": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "Retrieves the basic information about the API",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/types.ApiSummary"
}
}
}
}
},
"/v1/users/login": { "/v1/users/login": {
"post": { "post": {
"consumes": [ "consumes": [
@ -718,6 +706,25 @@
} }
} }
} }
},
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Deletes the user account",
"responses": {
"204": {
"description": ""
}
}
} }
}, },
"/v1/users/self/password": { "/v1/users/self/password": {

View file

@ -580,23 +580,6 @@ info:
title: Go API Templates title: Go API Templates
version: "1.0" version: "1.0"
paths: paths:
/status:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Result'
- properties:
item:
$ref: '#/definitions/types.ApiSummary'
type: object
summary: Retrieves the basic information about the API
tags:
- Base
/v1/items: /v1/items:
get: get:
produces: produces:
@ -888,6 +871,18 @@ paths:
summary: updates a location summary: updates a location
tags: tags:
- Locations - Locations
/v1/status:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/types.ApiSummary'
summary: Retrieves the basic information about the API
tags:
- Base
/v1/users/login: /v1/users/login:
post: post:
consumes: consumes:
@ -955,6 +950,17 @@ paths:
tags: tags:
- User - User
/v1/users/self: /v1/users/self:
delete:
produces:
- application/json
responses:
"204":
description: ""
security:
- Bearer: []
summary: Deletes the user account
tags:
- User
get: get:
produces: produces:
- application/json - application/json

View file

@ -30,6 +30,7 @@ func main() {
// Logger Init // Logger Init
// zerolog.TimeFieldFormat = zerolog.TimeFormatUnix // zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
log.Level(zerolog.DebugLevel)
cfgFile := "config.yml" cfgFile := "config.yml"
@ -89,7 +90,7 @@ func run(cfg *config.Config) error {
// ========================================================================= // =========================================================================
// Start Reoccurring Tasks // Start Reoccurring Tasks
go app.StartReoccurringTasks(time.Duration(24)*time.Hour, func() { go app.StartBgTask(time.Duration(24)*time.Hour, func() {
_, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background()) _, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background())
if err != nil { if err != nil {
log.Error(). log.Error().

View file

View file

@ -1,17 +1,27 @@
package main package main
import ( import (
"embed"
"errors"
"fmt" "fmt"
"io"
"io/fs"
"mime"
"net/http" "net/http"
"path"
"path/filepath"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/hay-kot/content/backend/app/api/base"
_ "github.com/hay-kot/content/backend/app/api/docs" _ "github.com/hay-kot/content/backend/app/api/docs"
v1 "github.com/hay-kot/content/backend/app/api/v1" v1 "github.com/hay-kot/content/backend/app/api/v1"
"github.com/hay-kot/content/backend/internal/repo" "github.com/hay-kot/content/backend/internal/repo"
"github.com/rs/zerolog/log"
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
) )
//go:embed all:public/*
var public embed.FS
const prefix = "/api" const prefix = "/api"
// registerRoutes registers all the routes for the API // registerRoutes registers all the routes for the API
@ -22,54 +32,54 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
// ========================================================================= // =========================================================================
// Base Routes // Base Routes
DumpEmbedContents()
r.Get("/swagger/*", httpSwagger.Handler( r.Get("/swagger/*", httpSwagger.Handler(
httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)), httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)),
)) ))
// Server Favicon
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/favicon.ico")
})
baseHandler := base.NewBaseController(a.server)
r.Get(prefix+"/status", baseHandler.HandleBase(func() bool { return true }, "v1"))
// ========================================================================= // =========================================================================
// API Version 1 // API Version 1
v1Base := v1.BaseUrlFunc(prefix) v1Base := v1.BaseUrlFunc(prefix)
v1Ctrl := v1.NewControllerV1(a.services)
{ {
v1Handlers := v1.NewControllerV1(a.services) r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, "v1"))
r.Post(v1Base("/users/register"), v1Handlers.HandleUserRegistration())
r.Post(v1Base("/users/login"), v1Handlers.HandleAuthLogin()) r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration())
r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin())
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(a.mwAuthToken) r.Use(a.mwAuthToken)
r.Get(v1Base("/users/self"), v1Handlers.HandleUserSelf()) r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf())
r.Put(v1Base("/users/self"), v1Handlers.HandleUserUpdate()) r.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate())
r.Put(v1Base("/users/self/password"), v1Handlers.HandleUserUpdatePassword()) r.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete())
r.Post(v1Base("/users/logout"), v1Handlers.HandleAuthLogout()) r.Put(v1Base("/users/self/password"), v1Ctrl.HandleUserUpdatePassword())
r.Get(v1Base("/users/refresh"), v1Handlers.HandleAuthRefresh()) r.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout())
r.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh())
r.Get(v1Base("/locations"), v1Handlers.HandleLocationGetAll()) r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll())
r.Post(v1Base("/locations"), v1Handlers.HandleLocationCreate()) r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate())
r.Get(v1Base("/locations/{id}"), v1Handlers.HandleLocationGet()) r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet())
r.Put(v1Base("/locations/{id}"), v1Handlers.HandleLocationUpdate()) r.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate())
r.Delete(v1Base("/locations/{id}"), v1Handlers.HandleLocationDelete()) r.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete())
r.Get(v1Base("/labels"), v1Handlers.HandleLabelsGetAll()) r.Get(v1Base("/labels"), v1Ctrl.HandleLabelsGetAll())
r.Post(v1Base("/labels"), v1Handlers.HandleLabelsCreate()) r.Post(v1Base("/labels"), v1Ctrl.HandleLabelsCreate())
r.Get(v1Base("/labels/{id}"), v1Handlers.HandleLabelGet()) r.Get(v1Base("/labels/{id}"), v1Ctrl.HandleLabelGet())
r.Put(v1Base("/labels/{id}"), v1Handlers.HandleLabelUpdate()) r.Put(v1Base("/labels/{id}"), v1Ctrl.HandleLabelUpdate())
r.Delete(v1Base("/labels/{id}"), v1Handlers.HandleLabelDelete()) r.Delete(v1Base("/labels/{id}"), v1Ctrl.HandleLabelDelete())
r.Get(v1Base("/items"), v1Handlers.HandleItemsGetAll()) r.Get(v1Base("/items"), v1Ctrl.HandleItemsGetAll())
r.Post(v1Base("/items"), v1Handlers.HandleItemsCreate()) r.Post(v1Base("/items"), v1Ctrl.HandleItemsCreate())
r.Get(v1Base("/items/{id}"), v1Handlers.HandleItemGet()) r.Get(v1Base("/items/{id}"), v1Ctrl.HandleItemGet())
r.Put(v1Base("/items/{id}"), v1Handlers.HandleItemUpdate()) r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate())
r.Delete(v1Base("/items/{id}"), v1Handlers.HandleItemDelete()) r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete())
}) })
} }
r.NotFound(NotFoundHandler)
return r return r
} }
@ -78,7 +88,7 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
func (a *app) LogRoutes(r *chi.Mux) { func (a *app) LogRoutes(r *chi.Mux) {
desiredSpaces := 10 desiredSpaces := 10
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { walkFunc := func(method string, route string, handler http.Handler, middleware ...func(http.Handler) http.Handler) error {
text := "[" + method + "]" text := "[" + method + "]"
for len(text) < desiredSpaces { for len(text) < desiredSpaces {
@ -93,3 +103,56 @@ func (a *app) LogRoutes(r *chi.Mux) {
fmt.Printf("Logging err: %s\n", err.Error()) fmt.Printf("Logging err: %s\n", err.Error())
} }
} }
var ErrDir = errors.New("path is dir")
func init() {
mime.AddExtensionType(".js", "application/javascript")
mime.AddExtensionType(".mjs", "application/javascript")
}
func tryRead(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error {
f, err := fs.Open(path.Join(prefix, requestedPath))
if err != nil {
return err
}
defer f.Close()
stat, _ := f.Stat()
if stat.IsDir() {
return ErrDir
}
contentType := mime.TypeByExtension(filepath.Ext(requestedPath))
w.Header().Set("Content-Type", contentType)
_, err = io.Copy(w, f)
return err
}
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
err := tryRead(public, "public", r.URL.Path, w)
if err == nil {
return
}
log.Debug().
Str("path", r.URL.Path).
Msg("served from embed not found - serving index.html")
err = tryRead(public, "public", "index.html", w)
if err != nil {
panic(err)
}
}
func DumpEmbedContents() {
// recursively prints all contents in the embed.FS
err := fs.WalkDir(public, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
fmt.Println(path)
return nil
})
if err != nil {
panic(err)
}
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/internal/repo" "github.com/hay-kot/content/backend/internal/repo"
"github.com/hay-kot/content/backend/internal/types" "github.com/hay-kot/content/backend/internal/types"
"github.com/hay-kot/content/backend/pkgs/hasher" "github.com/hay-kot/content/backend/pkgs/hasher"
@ -60,25 +61,28 @@ func (a *app) SeedDatabase(repos *repo.AllRepos) {
log.Fatal().Err(err).Msg("failed to create default group") log.Fatal().Err(err).Msg("failed to create default group")
} }
for _, user := range a.conf.Seed.Users { for _, seedUser := range a.conf.Seed.Users {
// Check if User Exists // Check if User Exists
usr, _ := repos.Users.GetOneEmail(context.Background(), user.Email) usr, err := repos.Users.GetOneEmail(context.Background(), seedUser.Email)
if err != nil && !ent.IsNotFound(err) {
log.Fatal().Err(err).Msg("failed to get user")
}
if usr.ID != uuid.Nil { if usr != nil && usr.ID != uuid.Nil {
log.Info().Str("email", user.Email).Msg("user already exists, skipping") log.Info().Str("email", seedUser.Email).Msg("user already exists, skipping")
continue continue
} }
hashedPw, err := hasher.HashPassword(user.Password) hashedPw, err := hasher.HashPassword(seedUser.Password)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("failed to hash password") log.Fatal().Err(err).Msg("failed to hash password")
} }
_, err = repos.Users.Create(context.Background(), types.UserCreate{ _, err = repos.Users.Create(context.Background(), types.UserCreate{
Name: user.Name, Name: seedUser.Name,
Email: user.Email, Email: seedUser.Email,
IsSuperuser: user.IsSuperuser, IsSuperuser: seedUser.IsSuperuser,
Password: hashedPw, Password: hashedPw,
GroupID: group.ID, GroupID: group.ID,
}) })
@ -87,6 +91,6 @@ func (a *app) SeedDatabase(repos *repo.AllRepos) {
log.Fatal().Err(err).Msg("failed to create user") log.Fatal().Err(err).Msg("failed to create user")
} }
log.Info().Str("email", user.Email).Msg("created user") log.Info().Str("email", seedUser.Email).Msg("created user")
} }
} }

View file

@ -1,7 +1,11 @@
package v1 package v1
import ( import (
"net/http"
"github.com/hay-kot/content/backend/internal/services" "github.com/hay-kot/content/backend/internal/services"
"github.com/hay-kot/content/backend/internal/types"
"github.com/hay-kot/content/backend/pkgs/server"
) )
type V1Controller struct { type V1Controller struct {
@ -24,3 +28,22 @@ func NewControllerV1(svc *services.AllServices) *V1Controller {
return ctrl return ctrl
} }
type ReadyFunc func() bool
// HandleBase godoc
// @Summary Retrieves the basic information about the API
// @Tags Base
// @Produce json
// @Success 200 {object} types.ApiSummary
// @Router /v1/status [GET]
func (ctrl *V1Controller) HandleBase(ready ReadyFunc, versions ...string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
server.Respond(w, http.StatusOK, types.ApiSummary{
Healthy: ready(),
Versions: versions,
Title: "Go API Template",
Message: "Welcome to the Go API Template Application!",
})
}
}

View file

@ -1,6 +1,8 @@
package v1 package v1
import ( import (
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -16,3 +18,25 @@ func Test_NewHandlerV1(t *testing.T) {
assert.Equal(t, "/testing/v1/v1/abc123", v1Base("/abc123")) assert.Equal(t, "/testing/v1/v1/abc123", v1Base("/abc123"))
assert.Equal(t, "/testing/v1/v1/abc123", v1Base("/abc123")) assert.Equal(t, "/testing/v1/v1/abc123", v1Base("/abc123"))
} }
func TestHandlersv1_HandleBase(t *testing.T) {
// Setup
hdlrFunc := mockHandler.HandleBase(func() bool { return true }, "v1")
// Call Handler Func
rr := httptest.NewRecorder()
hdlrFunc(rr, nil)
// Validate Status Code
if rr.Code != http.StatusOK {
t.Errorf("Expected status code to be %d, got %d", http.StatusOK, rr.Code)
}
// Validate Json Payload
expected := `{"health":true,"versions":["v1"],"title":"Go API Template","message":"Welcome to the Go API Template Application!"}`
if rr.Body.String() != expected {
t.Errorf("Expected json to be %s, got %s", expected, rr.Body.String())
}
}

View file

@ -58,7 +58,7 @@ func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc {
} }
} }
// HandleUserUpdate godoc // HandleUserSelfUpdate godoc
// @Summary Update the current user // @Summary Update the current user
// @Tags User // @Tags User
// @Produce json // @Produce json
@ -66,7 +66,7 @@ func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc {
// @Success 200 {object} server.Result{item=types.UserUpdate} // @Success 200 {object} server.Result{item=types.UserUpdate}
// @Router /v1/users/self [PUT] // @Router /v1/users/self [PUT]
// @Security Bearer // @Security Bearer
func (ctrl *V1Controller) HandleUserUpdate() http.HandlerFunc { func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateData := types.UserUpdate{} updateData := types.UserUpdate{}
if err := server.Decode(r, &updateData); err != nil { if err := server.Decode(r, &updateData); err != nil {
@ -99,3 +99,22 @@ func (ctrl *V1Controller) HandleUserUpdatePassword() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
} }
} }
// HandleUserSelfDelete godoc
// @Summary Deletes the user account
// @Tags User
// @Produce json
// @Success 204
// @Router /v1/users/self [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleUserSelfDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor := services.UseUserCtx(r.Context())
if err := ctrl.svc.User.DeleteSelf(r.Context(), actor.ID); err != nil {
server.RespondError(w, http.StatusInternalServerError, err)
return
}
server.Respond(w, http.StatusNoContent, nil)
}
}

View file

@ -4,8 +4,8 @@ swagger:
host: localhost:7745 host: localhost:7745
scheme: http scheme: http
web: web:
port: 3915 port: 7745
host: 127.0.0.1 host:
database: database:
driver: sqlite3 driver: sqlite3
sqlite-url: ./ent.db?_fk=1 sqlite-url: ./ent.db?_fk=1

View file

@ -2,6 +2,7 @@ package schema
import ( import (
"entgo.io/ent" "entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema/edge" "entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field" "entgo.io/ent/schema/field"
"github.com/hay-kot/content/backend/ent/schema/mixins" "github.com/hay-kot/content/backend/ent/schema/mixins"
@ -33,9 +34,17 @@ func (Group) Fields() []ent.Field {
// Edges of the Home. // Edges of the Home.
func (Group) Edges() []ent.Edge { func (Group) Edges() []ent.Edge {
return []ent.Edge{ return []ent.Edge{
edge.To("users", User.Type), edge.To("users", User.Type).Annotations(entsql.Annotation{
edge.To("locations", Location.Type), OnDelete: entsql.Cascade,
edge.To("items", Item.Type), }),
edge.To("labels", Label.Type), edge.To("locations", Location.Type).Annotations(entsql.Annotation{
OnDelete: entsql.Cascade,
}),
edge.To("items", Item.Type).Annotations(entsql.Annotation{
OnDelete: entsql.Cascade,
}),
edge.To("labels", Label.Type).Annotations(entsql.Annotation{
OnDelete: entsql.Cascade,
}),
} }
} }

View file

@ -2,6 +2,7 @@ package schema
import ( import (
"entgo.io/ent" "entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema/edge" "entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field" "entgo.io/ent/schema/field"
"github.com/google/uuid" "github.com/google/uuid"
@ -73,7 +74,9 @@ func (Item) Edges() []ent.Edge {
edge.From("location", Location.Type). edge.From("location", Location.Type).
Ref("items"). Ref("items").
Unique(), Unique(),
edge.To("fields", ItemField.Type), edge.To("fields", ItemField.Type).Annotations(entsql.Annotation{
OnDelete: entsql.Cascade,
}),
edge.From("label", Label.Type). edge.From("label", Label.Type).
Ref("items"), Ref("items"),
} }

View file

@ -2,6 +2,7 @@ package schema
import ( import (
"entgo.io/ent" "entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema/edge" "entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field" "entgo.io/ent/schema/field"
"github.com/hay-kot/content/backend/ent/schema/mixins" "github.com/hay-kot/content/backend/ent/schema/mixins"
@ -44,6 +45,8 @@ func (User) Edges() []ent.Edge {
Ref("users"). Ref("users").
Required(). Required().
Unique(), Unique(),
edge.To("auth_tokens", AuthTokens.Type), edge.To("auth_tokens", AuthTokens.Type).Annotations(entsql.Annotation{
OnDelete: entsql.Cascade,
}),
} }
} }

View file

@ -33,7 +33,7 @@ type SwaggerConf struct {
type WebConfig struct { type WebConfig struct {
Port string `yaml:"port" conf:"default:7745"` Port string `yaml:"port" conf:"default:7745"`
Host string `yaml:"host" conf:"default:127.0.0.1"` Host string `yaml:"host"`
} }
// NewConfig parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the // NewConfig parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the

View file

@ -14,23 +14,17 @@ type EntUserRepository struct {
} }
func (e *EntUserRepository) GetOneId(ctx context.Context, id uuid.UUID) (*ent.User, error) { func (e *EntUserRepository) GetOneId(ctx context.Context, id uuid.UUID) (*ent.User, error) {
usr, err := e.db.User.Query().Where(user.ID(id)).Only(ctx) return e.db.User.Query().
Where(user.ID(id)).
if err != nil { WithGroup().
return usr, err Only(ctx)
}
return usr, nil
} }
func (e *EntUserRepository) GetOneEmail(ctx context.Context, email string) (*ent.User, error) { func (e *EntUserRepository) GetOneEmail(ctx context.Context, email string) (*ent.User, error) {
usr, err := e.db.User.Query().Where(user.Email(email)).Only(ctx) return e.db.User.Query().
Where(user.Email(email)).
if err != nil { WithGroup().
return usr, err Only(ctx)
}
return usr, nil
} }
func (e *EntUserRepository) GetAll(ctx context.Context) ([]*ent.User, error) { func (e *EntUserRepository) GetAll(ctx context.Context) ([]*ent.User, error) {
@ -59,7 +53,11 @@ func (e *EntUserRepository) Create(ctx context.Context, usr types.UserCreate) (*
SetGroupID(usr.GroupID). SetGroupID(usr.GroupID).
Save(ctx) Save(ctx)
return entUser, err if err != nil {
return entUser, err
}
return e.GetOneId(ctx, entUser.ID)
} }
func (e *EntUserRepository) Update(ctx context.Context, ID uuid.UUID, data types.UserUpdate) error { func (e *EntUserRepository) Update(ctx context.Context, ID uuid.UUID, data types.UserUpdate) error {

View file

@ -0,0 +1,20 @@
package mappers
import (
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/internal/types"
)
func ToOutUser(user *ent.User, err error) (*types.UserOut, error) {
if err != nil {
return &types.UserOut{}, err
}
return &types.UserOut{
ID: user.ID,
Name: user.Name,
Email: user.Email,
IsSuperuser: user.IsSuperuser,
GroupName: user.Edges.Group.Name,
GroupID: user.Edges.Group.ID,
}, nil
}

View file

@ -6,8 +6,8 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/internal/repo" "github.com/hay-kot/content/backend/internal/repo"
"github.com/hay-kot/content/backend/internal/services/mappers"
"github.com/hay-kot/content/backend/internal/types" "github.com/hay-kot/content/backend/internal/types"
"github.com/hay-kot/content/backend/pkgs/hasher" "github.com/hay-kot/content/backend/pkgs/hasher"
) )
@ -23,24 +23,6 @@ type UserService struct {
repos *repo.AllRepos repos *repo.AllRepos
} }
func ToOutUser(user *ent.User, err error) (*types.UserOut, error) {
if err != nil {
return &types.UserOut{}, err
}
return &types.UserOut{
ID: user.ID,
Name: user.Name,
Email: user.Email,
IsSuperuser: user.IsSuperuser,
GroupName: user.Edges.Group.Name,
GroupID: user.Edges.Group.ID,
}, nil
}
func (UserService) toOutUser(user *ent.User, err error) (*types.UserOut, error) {
return ToOutUser(user, err)
}
func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistration) (*types.UserOut, error) { func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistration) (*types.UserOut, error) {
group, err := svc.repos.Groups.Create(ctx, data.GroupName) group, err := svc.repos.Groups.Create(ctx, data.GroupName)
if err != nil { if err != nil {
@ -48,7 +30,6 @@ func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistr
} }
hashed, _ := hasher.HashPassword(data.User.Password) hashed, _ := hasher.HashPassword(data.User.Password)
usrCreate := types.UserCreate{ usrCreate := types.UserCreate{
Name: data.User.Name, Name: data.User.Name,
Email: data.User.Email, Email: data.User.Email,
@ -57,23 +38,22 @@ func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistr
GroupID: group.ID, GroupID: group.ID,
} }
return svc.toOutUser(svc.repos.Users.Create(ctx, usrCreate)) return mappers.ToOutUser(svc.repos.Users.Create(ctx, usrCreate))
} }
// GetSelf returns the user that is currently logged in based of the token provided within // GetSelf returns the user that is currently logged in based of the token provided within
func (svc *UserService) GetSelf(ctx context.Context, requestToken string) (*types.UserOut, error) { func (svc *UserService) GetSelf(ctx context.Context, requestToken string) (*types.UserOut, error) {
hash := hasher.HashToken(requestToken) hash := hasher.HashToken(requestToken)
return svc.toOutUser(svc.repos.AuthTokens.GetUserFromToken(ctx, hash)) return mappers.ToOutUser(svc.repos.AuthTokens.GetUserFromToken(ctx, hash))
} }
func (svc *UserService) UpdateSelf(ctx context.Context, ID uuid.UUID, data types.UserUpdate) (*types.UserOut, error) { func (svc *UserService) UpdateSelf(ctx context.Context, ID uuid.UUID, data types.UserUpdate) (*types.UserOut, error) {
err := svc.repos.Users.Update(ctx, ID, data) err := svc.repos.Users.Update(ctx, ID, data)
if err != nil { if err != nil {
return &types.UserOut{}, err return &types.UserOut{}, err
} }
return svc.toOutUser(svc.repos.Users.GetOneId(ctx, ID)) return mappers.ToOutUser(svc.repos.Users.GetOneId(ctx, ID))
} }
// ============================================================================ // ============================================================================
@ -120,3 +100,10 @@ func (svc *UserService) RenewToken(ctx context.Context, token string) (types.Use
return newToken, nil return newToken, nil
} }
// DeleteSelf deletes the user that is currently logged based of the provided UUID
// There is _NO_ protection against deleting the wrong user, as such this should only
// be used when the identify of the user has been confirmed.
func (svc *UserService) DeleteSelf(ctx context.Context, ID uuid.UUID) error {
return svc.repos.Users.Delete(ctx, ID)
}

View file

@ -1,10 +1,10 @@
version: "3.4" version: "3.4"
services: services:
gowebtemplate: homebox:
image: gowebtemplate image: homebox
build: build:
context: . context: .
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
ports: ports:
- 3001:7745 - 3100:7745

View file

@ -59,10 +59,8 @@
const item = props.items[index]; const item = props.items[index];
if (selectedIndexes.value[index]) { if (selectedIndexes.value[index]) {
console.log(value);
value.value = [...value.value, item]; value.value = [...value.value, item];
} else { } else {
console.log(value);
value.value = value.value.filter(itm => itm !== item); value.value = value.value.filter(itm => itm !== item);
} }
} }

View file

@ -38,8 +38,6 @@
const value = useVModel(props, 'modelValue', emit); const value = useVModel(props, 'modelValue', emit);
const valueLen = computed(() => { const valueLen = computed(() => {
console.log(value.value.length);
return value.value ? value.value.length : 0; return value.value ? value.value.length : 0;
}); });
</script> </script>

View file

@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { Requests } from '../../requests';
import { OverrideParts } from '../base/urls';
import { PublicApi } from '../public';
import * as config from '../../../test/config';
import { UserApi } from '../user';
function client() {
OverrideParts(config.BASE_URL, '/api/v1');
const requests = new Requests('');
return new PublicApi(requests);
}
function userClient(token: string) {
OverrideParts(config.BASE_URL, '/api/v1');
const requests = new Requests('', token);
return new UserApi(requests);
}
describe('[GET] /api/v1/status', () => {
it('basic query parameter', async () => {
const api = client();
const { response, data } = await api.status();
expect(response.status).toBe(200);
expect(data.health).toBe(true);
});
});
describe('first time user workflow (register, login)', () => {
const api = client();
const userData = {
groupName: 'test-group',
user: {
email: 'test-user@email.com',
name: 'test-user',
password: 'test-password',
},
};
it('user should be able to register', async () => {
const { response } = await api.register(userData);
expect(response.status).toBe(204);
});
it('user should be able to login', async () => {
const { response, data } = await api.login(userData.user.email, userData.user.password);
expect(response.status).toBe(200);
expect(data.token).toBeTruthy();
// Cleanup
const userApi = userClient(data.token);
{
const { response } = await userApi.deleteAccount();
expect(response.status).toBe(204);
}
});
});

View file

@ -1,31 +1,28 @@
export const prefix = '/api/v1'; const parts = {
host: 'http://localhost.com',
prefix: '/api/v1',
};
export type QueryValue = export function OverrideParts(host: string, prefix: string) {
| string parts.host = host;
| string[] parts.prefix = prefix;
| number }
| number[]
| boolean export type QueryValue = string | string[] | number | number[] | boolean | null | undefined;
| null
| undefined; export function UrlBuilder(rest: string, params: Record<string, QueryValue> = {}): string {
const url = new URL(parts.prefix + rest, parts.host);
export function UrlBuilder(
rest: string, for (const [key, value] of Object.entries(params)) {
params: Record<string, QueryValue> = {} if (Array.isArray(value)) {
): string { for (const item of value) {
// we use a stub base URL to leverage the URL class url.searchParams.append(key, String(item));
const url = new URL(prefix + rest, 'http://localhost.com'); }
} else {
for (const [key, value] of Object.entries(params)) { url.searchParams.append(key, String(value));
if (Array.isArray(value)) { }
for (const item of value) { }
url.searchParams.append(key, String(item));
} // we return the path only, without the base URL
} else { return url.toString().replace('http://localhost.com', '');
url.searchParams.append(key, String(value));
}
}
// we return the path only, without the base URL
return url.toString().replace('http://localhost.com', '');
} }

View file

@ -19,7 +19,18 @@ export type RegisterPayload = {
groupName: string; groupName: string;
}; };
export type StatusResult = {
health: boolean;
versions: string[];
title: string;
message: string;
};
export class PublicApi extends BaseAPI { export class PublicApi extends BaseAPI {
public status() {
return this.http.get<StatusResult>(UrlBuilder('/status'));
}
public login(username: string, password: string) { public login(username: string, password: string) {
return this.http.post<LoginPayload, LoginResult>(UrlBuilder('/users/login'), { return this.http.post<LoginPayload, LoginResult>(UrlBuilder('/users/login'), {
username, username,

View file

@ -36,4 +36,8 @@ export class UserApi extends BaseAPI {
public logout() { public logout() {
return this.http.post<object, void>(UrlBuilder('/users/logout'), {}); return this.http.post<object, void>(UrlBuilder('/users/logout'), {});
} }
public deleteAccount() {
return this.http.delete<void>(UrlBuilder('/users/self'));
}
} }

View file

@ -2,12 +2,14 @@ import { defineNuxtConfig } from 'nuxt';
// https://v3.nuxtjs.org/api/configuration/nuxt.config // https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({ export default defineNuxtConfig({
target: 'static',
ssr: false, ssr: false,
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt', '@vueuse/nuxt'], modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt', '@vueuse/nuxt'],
meta: { meta: {
title: 'Homebox', title: 'Homebox',
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' }], link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' }],
}, },
outDir: '../backend/app/api/public',
vite: { vite: {
server: { server: {
proxy: { proxy: {

View file

@ -1,15 +1,16 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt generate",
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"test": "vitest", "test:ci": "TEST_SHUTDOWN_API_SERVER=true vitest --run --config ./test/vitest.config.ts",
"test:ci": "vitest --run" "test:local": "TEST_SHUTDOWN_API_SERVER=false && vitest --run --config ./test/vitest.config.ts",
"test:watch": " TEST_SHUTDOWN_API_SERVER=false vitest --config ./test/vitest.config.ts"
}, },
"devDependencies": { "devDependencies": {
"isomorphic-fetch": "^3.0.0",
"nuxt": "3.0.0-rc.8", "nuxt": "3.0.0-rc.8",
"vitest": "^0.22.1" "vitest": "^0.22.1"
}, },

View file

@ -92,7 +92,6 @@
} }
toast.success('Label updated'); toast.success('Label updated');
console.log(data);
label.value = data; label.value = data;
updateModal.value = false; updateModal.value = false;
updating.value = false; updating.value = false;

View file

@ -92,7 +92,6 @@
} }
toast.success('Location updated'); toast.success('Location updated');
console.log(data);
location.value = data; location.value = data;
updateModal.value = false; updateModal.value = false;
updating.value = false; updating.value = false;

View file

@ -10,6 +10,7 @@ specifiers:
'@vueuse/nuxt': ^9.1.1 '@vueuse/nuxt': ^9.1.1
autoprefixer: ^10.4.8 autoprefixer: ^10.4.8
daisyui: ^2.24.0 daisyui: ^2.24.0
isomorphic-fetch: ^3.0.0
nuxt: 3.0.0-rc.8 nuxt: 3.0.0-rc.8
pinia: ^2.0.21 pinia: ^2.0.21
postcss: ^8.4.16 postcss: ^8.4.16
@ -33,6 +34,7 @@ dependencies:
vue: 3.2.38 vue: 3.2.38
devDependencies: devDependencies:
isomorphic-fetch: 3.0.0
nuxt: 3.0.0-rc.8 nuxt: 3.0.0-rc.8
vitest: 0.22.1 vitest: 0.22.1
@ -3180,6 +3182,15 @@ packages:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true dev: true
/isomorphic-fetch/3.0.0:
resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==}
dependencies:
node-fetch: 2.6.7
whatwg-fetch: 3.6.2
transitivePeerDependencies:
- encoding
dev: true
/jest-worker/26.6.2: /jest-worker/26.6.2:
resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
@ -5720,6 +5731,10 @@ packages:
/webpack-virtual-modules/0.4.4: /webpack-virtual-modules/0.4.4:
resolution: {integrity: sha512-h9atBP/bsZohWpHnr+2sic8Iecb60GxftXsWNLLLSqewgIsGzByd2gcIID4nXcG+3tNe4GQG3dLcff3kXupdRA==} resolution: {integrity: sha512-h9atBP/bsZohWpHnr+2sic8Iecb60GxftXsWNLLLSqewgIsGzByd2gcIID4nXcG+3tNe4GQG3dLcff3kXupdRA==}
/whatwg-fetch/3.6.2:
resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==}
dev: true
/whatwg-url/5.0.0: /whatwg-url/5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies: dependencies:

4
frontend/test/config.ts Normal file
View file

@ -0,0 +1,4 @@
export const PORT = "7745";
export const HOST = "http://127.0.0.1";
export const BASE_URL = HOST + ":" + PORT;

20
frontend/test/setup.ts Normal file
View file

@ -0,0 +1,20 @@
import { exec } from 'child_process';
import * as config from './config';
export const setup = () => {
console.log('Starting Client Tests');
console.log({
PORT: config.PORT,
HOST: config.HOST,
BASE_URL: config.BASE_URL,
});
};
export const teardown = () => {
if (process.env.TEST_SHUTDOWN_API_SERVER) {
const pc = exec('pkill -SIGTERM api'); // Kill background API process
pc.stdout.on('data', data => {
console.log(`stdout: ${data}`);
});
}
};

View file

@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
export default defineConfig({
test: {
globalSetup: "./test/setup.ts",
},
});