Changes
3 changed files (+291/-114)
-
-
@@ -0,0 +1,198 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "crypto/rand" "strings" "connectrpc.com/connect" "github.com/google/uuid" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func createUserSystemError(message string) (*connect.Response[workspaceV2.CreateUserResponse], error) { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }), nil } func createUserAuthError() (*connect.Response[workspaceV2.CreateUserResponse], error) { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } func (s *Service) CreateUser( ctx context.Context, req *connect.Request[workspaceV2.CreateUserRequest], ) (*connect.Response[workspaceV2.CreateUserResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "CreateUser", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return createUserAuthError() } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return createUserSystemError("Database error") } defer tx.Rollback() users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return createUserSystemError("Database error") } secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return createUserSystemError("Database error") } if err := event.UpdateProjections(tx, users, secret); err != nil { logger.Error("Failed to update projections", "error", err) return createUserSystemError("Database error") } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } name := req.Msg.GetName() if name == "" { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("name"), }, }, }), nil } if strings.Trim(name, " \r\n\t") != name { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_NameSurroundedBySpaces{ NameSurroundedBySpaces: "Name cannot contain space, CR, LF, Tab", }, }), nil } displayName := strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") if displayName == "" { displayName = name } password := req.Msg.GetPassword() if password == "" { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("password"), }, }, }), nil } if len(password) <= 8 { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_PasswordLessThanBytes{ PasswordLessThanBytes: 8, }, }), nil } // TODO: 通常ユーザでも権限次第で作成できるようにする if !user.GetIsAdmin() { return createUserAuthError() } for _, u := range users.Projection.Users { if u.GetName() == name { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_DuplicatedName{ DuplicatedName: name, }, }), nil } } id, err := uuid.NewRandom() if err != nil { logger.Error("Failed to generate UUID", "error", err) return createUserSystemError("Unable to issue a new ID") } keyID := make([]byte, 32) rand.Read(keyID) err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.CreateUser(id.String(), name, displayName, keyID), workspaceEvent.ConfigurePasswordLogin(id.String(), password), }) if err != nil { logger.Error("Failed to append user creation events", "error", err) return createUserSystemError("Database error") } // TODO: 通常ユーザの権限を保存する if req.Msg.GetIsAdmin() { err := event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.GrantAdminAccess(id.String()), }) if err != nil { logger.Error("Failed to append adming grant events", "error", err) return createUserSystemError("Database error") } } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return createUserSystemError("Database error") } logger.Debug("Created a new user", "id", id.String()) return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_Ok{ Ok: &workspaceV2.User{ Id: &workspaceV2.UserID{ Value: proto.String(id.String()), }, Name: proto.String(name), DisplayName: proto.String(displayName), IsAdmin: proto.Bool(req.Msg.GetIsAdmin()), }, }, }), nil }
-
-
-
@@ -6,14 +6,11 @@import ( "context" "net/http" "strings" "connectrpc.com/connect" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" workspaceV2connect "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect"
-
@@ -46,117 +43,6 @@ req *connect.Request[workspaceV2.UpdateRequest],) (*connect.Response[workspaceV2.UpdateResponse], error) { res := workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Not Implemented"), }, }, } return connect.NewResponse(&res), nil } // TODO: 通常の追加フローも書く func (s *Service) CreateUser( ctx context.Context, req *connect.Request[workspaceV2.CreateUserRequest], ) (*connect.Response[workspaceV2.CreateUserResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "CreateUser", ) tx, err := s.core.DB.Begin() if err != nil { return nil, err } defer tx.Rollback() users, err := projection.GetUsers(tx) if err != nil { return nil, err } secret, err := projection.GetLoginJwtSecret(tx) if err != nil { return nil, err } if err := event.UpdateProjections(tx, users, secret); err != nil { return nil, err } header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if token == nil { if err != nil { logger.Warn("Failed to load token from cookie", "error", err) } else { logger.Debug("Unauthorized request made") } return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } _, err = token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } name := req.Msg.GetName() if name == "" { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("name"), }, }, }), nil } if strings.Trim(name, " \r\n\t") != name { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_NameSurroundedBySpaces{ NameSurroundedBySpaces: "Name cannot contain space, CR, LF, Tab", }, }), nil } displayName := strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") if displayName == "" { displayName = name } password := req.Msg.GetPassword() if password == "" { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("password"), }, }, }), nil } if len(password) <= 8 { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_PasswordLessThanBytes{ PasswordLessThanBytes: 8, }, }), nil } res := workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Not Implemented"), },
-
-
-
@@ -0,0 +1,93 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestCreateUserGetOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) res, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } loginRes, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } l, ok := loginRes.Msg.Result.(*workspaceV2.LoginResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if l.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", l.Ok.GetName()) } } func TestCreateUserRejectUnauthorizedRequest(t *testing.T) { server, _ := setupLogin(t) client := v2connect.NewWorkspaceServiceClient(server.Client(), server.URL()) res, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := res.Msg.Result.(*workspaceV2.CreateUserResponse_AuthenticationError); !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected authentication_error, got %s", typeName.Type().Name()) } }
-