Changes
4 changed files (+552/-2)
-
-
@@ -164,3 +164,18 @@ },}, } } func DefineCustomAttributeDefinition(id string, displayName string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_CustomAttributeDefined{ CustomAttributeDefined: &workspaceEvent.CustomAttributeDefined{ Id: proto.String(id), DisplayName: proto.String(displayName), }, }, }, }, } }
-
-
-
@@ -5,12 +5,21 @@ package workspaceimport ( "context" "slices" "strings" "connectrpc.com/connect" "github.com/google/uuid" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" 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 putCustomAttributeDefinitionSystemError(
-
@@ -25,6 +34,22 @@ },}) } func putCustomAttributeDefinitionAuthError() *connect.Response[workspaceV2.PutCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func putCustomAttributeDefinitionPermError() *connect.Response[workspaceV2.PutCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func (s *Service) PutCustomAttributeDefinition( ctx context.Context, req *connect.Request[workspaceV2.PutCustomAttributeDefinitionRequest],
-
@@ -32,10 +57,186 @@ ) (*connect.Response[workspaceV2.PutCustomAttributeDefinitionResponse], error, ) { _ = s.core.Logger.With( logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "PutCustomAttributeDefinition", ) return putCustomAttributeDefinitionSystemError("Not implemented"), nil header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return putCustomAttributeDefinitionAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return putCustomAttributeDefinitionAuthError(), nil } if !slices.Contains(user.Permissions, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) { return putCustomAttributeDefinitionPermError(), nil } displayName := strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") if displayName == "" { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("display_name"), }, }, }), nil } if req.Msg.Id == nil { // 新規作成 uuid, err := uuid.NewRandom() if err != nil { logger.Error("Failed to generate UUID", "error", err) return putCustomAttributeDefinitionSystemError("Unable to issue a new ID"), nil } id := "cf-" + uuid.String() for _, def := range workspace.Projection.CustomAttributes { if def.GetDisplayName() == displayName { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_DuplicatedDisplayName{ DuplicatedDisplayName: *proto.String(displayName), }, }), nil } } err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.DefineCustomAttributeDefinition(id, displayName), }) if err != nil { logger.Error( "Failed to append custom attribute define event for new one", "error", err, ) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace); err != nil { logger.Error("Failed to update workspace projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } workspace := projectionWorkspaceToMessage(workspace.Projection, users.Projection) for _, def := range workspace.CustomAttributeDefinition { if def.Id.GetValue() == id { logger.Debug("Defined a new custom attribute", "id", id) return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_Ok{ Ok: def, }, }), nil } } logger.Error( "Appending custom attribute definition succeeded, but the new one does not exist in projection", "id", id, ) return putCustomAttributeDefinitionSystemError("Unexpected error"), nil } // 更新 found := false for _, def := range workspace.Projection.CustomAttributes { if def.GetId() == req.Msg.Id.GetValue() { found = true break } } if !found { // TODO: NotFound にする (要 proto 変更) return putCustomAttributeDefinitionSystemError("CustomAttributeDefinition not found"), nil } updateId := req.Msg.Id.GetValue() err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.DefineCustomAttributeDefinition(updateId, displayName), }) if err != nil { logger.Error( "Failed to append custom attribute define event for existing one", "error", err, ) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace); err != nil { logger.Error("Failed to update workspace projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } ws := projectionWorkspaceToMessage(workspace.Projection, users.Projection) for _, def := range ws.CustomAttributeDefinition { if def.Id.GetValue() == updateId { logger.Debug("Updated a custom attribute", "id", updateId) return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_Ok{ Ok: def, }, }), nil } } logger.Error( "Updating custom attribute definition succeeded, but the updated one does not exist in projection", "id", updateId, ) return putCustomAttributeDefinitionSystemError("Unexpected error"), nil }
-
-
-
@@ -121,3 +121,52 @@ }return server, jar } // setupRestrictedUserLogin は最小権限のユーザを作成し、作成されたユーザで // ログインした状態までを設定した上で接続可能なサーバとクライアント向けの // Cookie を返す。 func setupRestrictedUserLogin(t *testing.T) (*memhttp.Server, http.CookieJar) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{}, }), ) if err != nil { t.Fatal(err) } _, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } return server, jar }
-
-
-
@@ -0,0 +1,285 @@// 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 TestPutCustomAttributeDefinitionOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) var fooID string // Create Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } fooID = v.Ok.Id.GetValue() } // Create Bar { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Bar"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } } // Update Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: proto.String(fooID), }, DisplayName: proto.String("FOO"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "FOO" { t.Fatalf("Expected \"FOO\", got \"%s\"", v.Ok.GetDisplayName()) } } // Get { res, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if len(v.Ok.CustomAttributeDefinition) != 2 { t.Fatalf("Expected 2 custom attribute definition, got %d", len(v.Ok.CustomAttributeDefinition)) } foo := v.Ok.CustomAttributeDefinition[0].GetDisplayName() if foo != "FOO" { t.Fatalf("Expected \"FOO\", got \"%s\"", foo) } bar := v.Ok.CustomAttributeDefinition[1].GetDisplayName() if bar != "Bar" { t.Fatalf("Expected \"Bar\", got \"%s\"", foo) } } } func TestPutCustomAttributeDefinitionUnauthroizedRequest(t *testing.T) { server := setupInitialAdmin(t) client := v2connect.NewWorkspaceServiceClient(server.Client(), server.URL()) res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_AuthenticationError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected authentication_error, got %s", typeName.Type().Name()) } } func TestPutCustomAttributeDefinitionPermissionError(t *testing.T) { server, jar := setupRestrictedUserLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } } func TestPutCustomAttributeDefinitionRejectsMissingDisplayName(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) // Missing field { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_MissingFieldError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected missing_field, got %s", typeName.Type().Name()) } if v.MissingFieldError.GetPath() != "display_name" { t.Fatalf("Expected \"display_name\", got \"%s\"", v.MissingFieldError.GetPath()) } } // Whitespace only { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String(" \n"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_MissingFieldError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected missing_field, got %s", typeName.Type().Name()) } if v.MissingFieldError.GetPath() != "display_name" { t.Fatalf("Expected \"display_name\", got \"%s\"", v.MissingFieldError.GetPath()) } } } func TestPutCustomAttributeDefinitionRejectsDuplicatedDisplayName(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) // Create Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } } // Create Foo again { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String(" Foo "), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_DuplicatedDisplayName) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected duplicated_display_name, got %s", typeName.Type().Name()) } if v.DuplicatedDisplayName != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.DuplicatedDisplayName) } } }
-