diff --git a/Makefile b/Makefile index 4108780..1719643 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,16 @@ docker-dev: docker compose up -d docker ps +docker-infra: + cd docker/compose ;\ + docker compose -f docker-compose.infra.yml up -d + docker ps + +docker-infra-clean: + cd docker/compose ;\ + docker compose -f docker-compose.infra.yml down -v + docker ps + docker-dev-build: cd docker/compose ;\ docker compose up -d --build @@ -36,7 +46,16 @@ test-nginx: .PHONY: unittests unittests: go test ./... -v -count=1 -cover -coverpkg=./... -coverprofile=profile.cov ./... + go tool cover -func profile.cov .PHONY: cover cover: go tool cover -html profile.cov -o coverage.html + + +.PHONY: test-all +test-all: docker-infra + go test -v -count=1 -cover -coverpkg=./... -coverprofile=profile.cov --tags e2e ./... + go tool cover -func profile.cov + $(MAKE) cover + $(MAKE) docker-infra-clean diff --git a/docker/compose/.env b/docker/compose/.env index 6acdb2d..64000bb 100644 --- a/docker/compose/.env +++ b/docker/compose/.env @@ -1,2 +1,2 @@ COMPOSE_PROJECT_NAME=vidi-dev -COMPOSE_FILE=docker-compose.infra.yml:docker-compose.api.yml:docker-compose.media.yml +COMPOSE_FILE=docker-compose.infra.yml:docker-compose.api.yml:docker-compose.media.yml:docker-compose.nginx.yml diff --git a/docker/compose/docker-compose.infra.yml b/docker/compose/docker-compose.infra.yml index cde81ee..0c9b775 100644 --- a/docker/compose/docker-compose.infra.yml +++ b/docker/compose/docker-compose.infra.yml @@ -8,18 +8,6 @@ x-logging: &logging max-file: "5" services: - nginx: - logging: *logging - restart: unless-stopped - image: nginx:1.25.3-bookworm - volumes: - - "./nginx.conf:/etc/nginx/nginx.conf:ro" - - "./player:/var/www/player:ro" - ports: - - "8080:80" - networks: - - vidi - minio: logging: *logging restart: unless-stopped diff --git a/docker/compose/docker-compose.nginx.yml b/docker/compose/docker-compose.nginx.yml new file mode 100644 index 0000000..1fb4ebb --- /dev/null +++ b/docker/compose/docker-compose.nginx.yml @@ -0,0 +1,26 @@ +--- +version: "3.8" + +x-logging: &logging + driver: "json-file" + options: + max-size: "100k" + max-file: "5" + +services: + nginx: + logging: *logging + restart: unless-stopped + image: nginx:1.25.3-bookworm + volumes: + - "./nginx.conf:/etc/nginx/nginx.conf:ro" + - "./player:/var/www/player:ro" + ports: + - "8080:80" + networks: + - vidi + +networks: + vidi: + name: vidi-net + driver: bridge diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..385aa11 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,135 @@ +//go:build e2e +// +build e2e + +package e2e + +import ( + "context" + "net/http" + "os" + "testing" + "time" + + common "github.com/adwski/vidi/internal/api/model" + "github.com/adwski/vidi/internal/api/user/model" + "github.com/adwski/vidi/internal/app/user" + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + var ( + done = make(chan struct{}) + ctx, cancel = context.WithCancel(context.Background()) + ) + + go func() { + user.NewApp().RunWithContextAndConfig(ctx, "userapi.yaml") + done <- struct{}{} + }() + + time.Sleep(time.Second) + + code := m.Run() + cancel() + <-done + defer func() { + os.Exit(code) + }() +} + +func TestE2EUserRegistration(t *testing.T) { + //------------------------------------------------------------------------------- + // Login with not-existent user + //------------------------------------------------------------------------------- + userLoginFail(t, &model.UserRequest{ + Username: "qweqweqwe", + Password: "asdasdasd", + }) + + //------------------------------------------------------------------------------- + // Register user + //------------------------------------------------------------------------------- + cookie := userRegister(t, &model.UserRequest{ + Username: "qweqweqwe", + Password: "asdasdasd", + }) + t.Logf("user is registered, token: %v", cookie.Value) + + //------------------------------------------------------------------------------- + // Login with existent user + //------------------------------------------------------------------------------- + cookie2 := userLogin(t, &model.UserRequest{ + Username: "qweqweqwe", + Password: "asdasdasd", + }) + t.Logf("user is logged in, token: %v", cookie2.Value) + + //------------------------------------------------------------------------------- + // Login with not-existent user + //------------------------------------------------------------------------------- + userLoginFail(t, &model.UserRequest{ + Username: "qweqweqwe1", + Password: "asdasdasd2", + }) +} + +func userRegister(t *testing.T, user *model.UserRequest) *http.Cookie { + t.Helper() + + resp, body := makeCommonRequest(t, "http://localhost:18081/api/user/register", user) + require.True(t, resp.IsSuccess()) + require.Empty(t, body.Error) + require.Equal(t, "registration complete", body.Message) + return getCookieWithToken(t, resp.Cookies()) +} + +func userLogin(t *testing.T, user *model.UserRequest) *http.Cookie { + t.Helper() + + resp, body := makeCommonRequest(t, "http://localhost:18081/api/user/login", user) + require.True(t, resp.IsSuccess()) + require.Empty(t, body.Error) + require.Equal(t, "login ok", body.Message) + return getCookieWithToken(t, resp.Cookies()) +} + +func userLoginFail(t *testing.T, user *model.UserRequest) { + t.Helper() + + resp, body := makeCommonRequest(t, "http://localhost:18081/api/user/login", user) + require.Truef(t, resp.IsError(), "user should not exist") + require.Empty(t, body.Message) + require.NotEmpty(t, body.Error) +} + +func makeCommonRequest(t *testing.T, url string, reqBody interface{}) (*resty.Response, *common.Response) { + t.Helper() + + var ( + body common.Response + ) + resp, err := resty.New().R().SetHeader("Accept", "application/json"). + SetError(&body). + SetResult(&body). + SetBody(reqBody).Post(url) + require.NoError(t, err) + return resp, &body +} + +func getCookieWithToken(t *testing.T, cookies []*http.Cookie) *http.Cookie { + t.Helper() + + var ( + userCookie *http.Cookie + ) + for _, cookie := range cookies { + if cookie.Name == "vidiSessID" { + userCookie = cookie + break + } + } + require.NotNilf(t, userCookie, "cookie should exist") + require.NotEmpty(t, userCookie.Value, "cookie should not be empty") + return userCookie +} diff --git a/e2e/userapi.yaml b/e2e/userapi.yaml new file mode 100644 index 0000000..219f9f0 --- /dev/null +++ b/e2e/userapi.yaml @@ -0,0 +1,8 @@ +log: + level: debug +server: + address: ":18081" +api: + prefix: /api/user +database: + dsn: postgres://userapi:userapi@localhost:5400/userapi?sslmode=disable diff --git a/internal/api/server/server.go b/internal/api/server/server.go index 2908102..c9e4ef3 100644 --- a/internal/api/server/server.go +++ b/internal/api/server/server.go @@ -54,6 +54,7 @@ func (s *Server) Run(ctx context.Context, wg *sync.WaitGroup, errc chan<- error) defer wg.Done() if s.srv.Handler == nil { errc <- errors.New("server handler is not set") + return } errSrv := make(chan error) diff --git a/internal/api/server/server_test.go b/internal/api/server/server_test.go new file mode 100644 index 0000000..e5e4a0b --- /dev/null +++ b/internal/api/server/server_test.go @@ -0,0 +1,137 @@ +package server + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewServerNilLogger(t *testing.T) { + _, errS := NewServer(&Config{ + ListenAddress: ":8888", + ReadTimeout: time.Second, + ReadHeaderTimeout: time.Second, + WriteTimeout: time.Second, + IdleTimeout: time.Second, + }) + assert.ErrorContains(t, errS, "nil logger") +} + +func TestServer_RunNoHandler(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + + s, errS := NewServer(&Config{ + Logger: logger, + ListenAddress: ":8881", + ReadTimeout: time.Second, + ReadHeaderTimeout: time.Second, + WriteTimeout: time.Second, + IdleTimeout: time.Second, + }) + require.NoError(t, errS) + + var ( + ctx, cancel = context.WithCancel(context.Background()) + wg = &sync.WaitGroup{} + errc = make(chan error) + ) + + wg.Add(1) + go s.Run(ctx, wg, errc) + + select { + case err := <-errc: + require.ErrorContains(t, err, "server handler is not set") + case <-time.After(time.Second): + assert.Fail(t, "no error was returned") + } + cancel() + wg.Wait() +} + +func TestServer_RunCancel(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + + s, errS := NewServer(&Config{ + Logger: logger, + ListenAddress: ":8888", + ReadTimeout: time.Second, + ReadHeaderTimeout: time.Second, + WriteTimeout: time.Second, + IdleTimeout: time.Second, + }) + require.NoError(t, errS) + + s.SetHandler(stub{}) + + var ( + ctx, cancel = context.WithCancel(context.Background()) + wg = &sync.WaitGroup{} + errc = make(chan error) + done = make(chan struct{}) + ) + + wg.Add(1) + go s.Run(ctx, wg, errc) + + go func() { + cancel() + wg.Wait() + done <- struct{}{} + }() + + select { + case <-done: + case <-time.After(time.Second): + assert.Fail(t, "did not shutdown in time") + } +} + +func TestServer_RunError(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + + s, errS := NewServer(&Config{ + Logger: logger, + ListenAddress: ":888888", + ReadTimeout: time.Second, + ReadHeaderTimeout: time.Second, + WriteTimeout: time.Second, + IdleTimeout: time.Second, + }) + require.NoError(t, errS) + + s.SetHandler(stub{}) + + var ( + ctx, cancel = context.WithCancel(context.Background()) + wg = &sync.WaitGroup{} + errc = make(chan error) + ) + defer cancel() + + wg.Add(1) + go s.Run(ctx, wg, errc) + + select { + case err := <-errc: + assert.Error(t, err) + case <-time.After(time.Second): + assert.Fail(t, "did not return error") + } + wg.Wait() +} + +type stub struct{} + +func (s stub) ServeHTTP(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) +} diff --git a/internal/api/user/auth/auth_test.go b/internal/api/user/auth/auth_test.go new file mode 100644 index 0000000..7c9a0ff --- /dev/null +++ b/internal/api/user/auth/auth_test.go @@ -0,0 +1,125 @@ +package auth + +import ( + "testing" + "time" + + "github.com/adwski/vidi/internal/api/user/model" + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuth_NewTokenForUser(t *testing.T) { + a, err := NewAuth(&Config{ + Secret: "superSecret", + Domain: "domain.com", + HTTPS: false, + Expiration: time.Hour, + }) + require.NoError(t, err) + + token, err := a.NewTokenForUser(&model.User{ + ID: "qweqweqwe", + Name: "name", + }) + require.NoError(t, err) + + jt, err := jwt.ParseWithClaims(token, &Claims{}, func(_ *jwt.Token) (interface{}, error) { + return a.secret, nil + }) + require.NoError(t, err) + + claims, ok := jt.Claims.(*Claims) + require.True(t, ok, "token must have claims") + + assert.Equal(t, "qweqweqwe", claims.UserID) + assert.Equal(t, "name", claims.Name) +} + +func TestAuth_NewTokenForService(t *testing.T) { + a, err := NewAuth(&Config{ + Secret: "superSecret", + Domain: "domain.com", + HTTPS: false, + Expiration: time.Hour, + }) + require.NoError(t, err) + + token, err := a.NewTokenForService("svc1") + require.NoError(t, err) + + jt, err := jwt.ParseWithClaims(token, &Claims{}, func(_ *jwt.Token) (interface{}, error) { + return a.secret, nil + }) + require.NoError(t, err) + + claims, ok := jt.Claims.(*Claims) + require.True(t, ok, "token must have claims") + + assert.Equal(t, "svc1", claims.Name) + assert.True(t, claims.IsService()) + assert.True(t, claims.Role == roleNameService) +} + +func TestAuth_CookieForUser(t *testing.T) { + a, err := NewAuth(&Config{ + Secret: "superSecret", + Domain: "domain.com", + HTTPS: false, + Expiration: time.Hour, + }) + require.NoError(t, err) + + cookie, err := a.CookieForUser(&model.User{ + ID: "qweqweqwe", + Name: "name", + }) + require.NoError(t, err) + + assert.Equal(t, jwtCookieName, cookie.Name) + assert.Equal(t, "domain.com", cookie.Domain) + assert.False(t, cookie.Secure) + assert.WithinDuration(t, time.Now().Add(time.Hour), cookie.Expires, time.Second) + + jt, err := jwt.ParseWithClaims(cookie.Value, &Claims{}, func(_ *jwt.Token) (interface{}, error) { + return a.secret, nil + }) + require.NoError(t, err) + require.True(t, jt.Valid) + + claims, ok := jt.Claims.(*Claims) + require.True(t, ok, "token must have claims") + + assert.Equal(t, "qweqweqwe", claims.UserID) + assert.Equal(t, "name", claims.Name) +} + +func TestAuth_MiddlewareService(t *testing.T) { + a, err := NewAuth(&Config{ + Secret: "superSecret", + Domain: "domain.com", + HTTPS: false, + Expiration: time.Hour, + }) + require.NoError(t, err) + + echoMW := a.MiddlewareService() + var echoMWType echo.MiddlewareFunc + assert.IsType(t, echoMWType, echoMW) +} + +func TestAuth_MiddlewareUser(t *testing.T) { + a, err := NewAuth(&Config{ + Secret: "superSecret", + Domain: "domain.com", + HTTPS: false, + Expiration: time.Hour, + }) + require.NoError(t, err) + + echoMW := a.MiddlewareUser() + var echoMWType echo.MiddlewareFunc + assert.IsType(t, echoMWType, echoMW) +} diff --git a/internal/app/app.go b/internal/app/app.go index 5e6b02b..44718c4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,6 +19,8 @@ import ( const ( envPrefix = "VIDI" + defaultConfigName = "config" + defaultReadHeaderTimeout = time.Second defaultReadTimeout = 5 * time.Second defaultWriteTimeout = 5 * time.Second @@ -68,10 +70,14 @@ func (app *App) Viper() *config.ViperEC { } func (app *App) Run() int { + return app.RunWithContextAndConfig(context.Background(), defaultConfigName) +} + +func (app *App) RunWithContextAndConfig(ctx context.Context, configFileName string) int { // -------------------------------------------------- // configure // -------------------------------------------------- - code := app.configure() + code := app.configure(configFileName) if code != 0 { return code } @@ -79,11 +85,11 @@ func (app *App) Run() int { // -------------------------------------------------- // start app // -------------------------------------------------- - return app.run() + return app.run(ctx) } -func (app *App) run() int { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) +func (app *App) run(ctx context.Context) int { + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) defer cancel() runners, closers, ok := app.initializer(ctx) @@ -113,11 +119,11 @@ func (app *App) run() int { return 0 } -func (app *App) configure() int { +func (app *App) configure(configName string) int { // Set defaults app.setConfigDefaults() // Try to read the config ignoring any errors - err := app.readConfig() + err := app.readConfig(configName) if err != nil { app.defaultLogger.Error("config error", zap.Error(err)) return 1 @@ -131,8 +137,8 @@ func (app *App) configure() int { return 0 } -func (app *App) readConfig() error { - app.viper.SetConfigName("config") +func (app *App) readConfig(name string) error { + app.viper.SetConfigName(name) app.viper.SetConfigType("yaml") app.viper.AddConfigPath(".") if err := app.viper.ReadInConfig(); err != nil { diff --git a/internal/app/vidicli/api.go b/internal/app/vidicli/api.go index bcb9cf8..489f588 100644 --- a/internal/app/vidicli/api.go +++ b/internal/app/vidicli/api.go @@ -36,11 +36,13 @@ func createServiceToken(name, secret string, expiration time.Duration) { }) if err != nil { logger.Error("cannot init authenticator", zap.Error(err)) + return } token, errT := au.NewTokenForService(name) if errT != nil { logger.Error("cannot create token", zap.Error(errT)) + return } fmt.Println(token) } diff --git a/internal/media/server/server.go b/internal/media/server/server.go index 5f5afb8..78702db 100644 --- a/internal/media/server/server.go +++ b/internal/media/server/server.go @@ -48,6 +48,7 @@ func (s *Server) Run(ctx context.Context, wg *sync.WaitGroup, errc chan<- error) defer wg.Done() if s.srv.Handler == nil { errc <- errors.New("server handler is not set") + return } errSrv := make(chan error) diff --git a/internal/media/server/server_test.go b/internal/media/server/server_test.go new file mode 100644 index 0000000..e43a7bf --- /dev/null +++ b/internal/media/server/server_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/valyala/fasthttp" + "go.uber.org/zap" +) + +func TestServer_RunNoHandler(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + + s := New(&Config{ + Logger: logger, + ListenAddress: ":8881", + ReadTimeout: time.Second, + WriteTimeout: time.Second, + IdleTimeout: time.Second, + }) + + var ( + ctx, cancel = context.WithCancel(context.Background()) + wg = &sync.WaitGroup{} + errc = make(chan error) + ) + + wg.Add(1) + go s.Run(ctx, wg, errc) + + select { + case err := <-errc: + require.ErrorContains(t, err, "server handler is not set") + case <-time.After(time.Second): + assert.Fail(t, "no error was returned") + } + cancel() + wg.Wait() +} + +func TestServer_RunCancel(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + + s := New(&Config{ + Handler: stub, + Logger: logger, + ListenAddress: ":8881", + ReadTimeout: time.Second, + WriteTimeout: time.Second, + IdleTimeout: time.Second, + }) + + var ( + ctx, cancel = context.WithCancel(context.Background()) + wg = &sync.WaitGroup{} + errc = make(chan error) + done = make(chan struct{}) + ) + + wg.Add(1) + go s.Run(ctx, wg, errc) + + go func() { + cancel() + wg.Wait() + done <- struct{}{} + }() + + select { + case <-done: + case <-time.After(time.Second): + assert.Fail(t, "did not shutdown in time") + } +} + +func TestServer_RunError(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + + s := New(&Config{ + Handler: stub, + Logger: logger, + ListenAddress: ":888888", + ReadTimeout: time.Second, + WriteTimeout: time.Second, + IdleTimeout: time.Second, + }) + + var ( + ctx, cancel = context.WithCancel(context.Background()) + wg = &sync.WaitGroup{} + errc = make(chan error) + ) + defer cancel() + + wg.Add(1) + go s.Run(ctx, wg, errc) + + select { + case err := <-errc: + assert.Error(t, err) + case <-time.After(time.Second): + assert.Fail(t, "did not return error") + } + wg.Wait() +} + +var stub = func(ctx *fasthttp.RequestCtx) { + ctx.SetStatusCode(fasthttp.StatusOK) +} diff --git a/internal/media/uploader/service.go b/internal/media/uploader/service.go index 070877f..e6bbda9 100644 --- a/internal/media/uploader/service.go +++ b/internal/media/uploader/service.go @@ -132,6 +132,18 @@ func (svc *Service) handleUpload(ctx *fasthttp.RequestCtx) { Kind: event.KindUpdateStatus, }) + if err = svc.pipeBodyToS3(ctx, sess, size); err != nil { + ctx.Error(internalError, fasthttp.StatusInternalServerError) + return + } + ctx.SetStatusCode(fasthttp.StatusNoContent) + // -------------------------------------------------- + // Postprocessing phase + // -------------------------------------------------- + go svc.postProcess(sess) +} + +func (svc *Service) pipeBodyToS3(ctx *fasthttp.RequestCtx, sess *session.Session, size int) error { defer func() { if errC := ctx.Request.CloseBodyStream(); errC != nil { svc.logger.Error("error while closing body stream", zap.Error(errC)) @@ -141,15 +153,15 @@ func (svc *Service) handleUpload(ctx *fasthttp.RequestCtx) { // Manually pipe data streams because there's no native way // to do it using fasthttp and s3 client together. var ( - r, w = io.Pipe() - done = make(chan struct{}) - errPut, errBody error + r, w = io.Pipe() + done = make(chan struct{}) + errPut, errBody, errR, errW error ) go func() { if errPut = svc.mediaS.Put(ctx, svc.getUploadArtifactName(sess), r, int64(size)); errPut != nil { svc.logger.Error("error while uploading artifact", zap.Error(errPut)) } - if errR := r.Close(); errR != nil { + if errR = r.Close(); errR != nil { svc.logger.Error("error closing pipe reader", zap.Error(errR)) } done <- struct{}{} @@ -158,22 +170,17 @@ func (svc *Service) handleUpload(ctx *fasthttp.RequestCtx) { if errBody = ctx.Request.BodyWriteTo(w); errBody != nil { svc.logger.Error("error while reading body", zap.Error(errBody)) } - if errW := w.Close(); errW != nil { + if errW = w.Close(); errW != nil { svc.logger.Error("error closing pipe writer", zap.Error(errW)) } done <- struct{}{} }() <-done <-done - if errBody != nil || errPut != nil { - ctx.Error(internalError, fasthttp.StatusInternalServerError) - return + if errPut != nil || errBody != nil || errR != nil || errW != nil { + return errors.New("pipe error") } - ctx.SetStatusCode(fasthttp.StatusNoContent) - // -------------------------------------------------- - // Postprocessing phase - // -------------------------------------------------- - go svc.postProcess(sess) + return nil } func (svc *Service) postProcess(sess *session.Session) {