memos/server/router/api/v1/space_service_test.go

369 lines
19 KiB
Go

package v1
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/fieldmaskpb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func createSpaceTestUser(ctx context.Context, t *testing.T, service *APIV1Service, username string, role store.Role) *store.User {
t.Helper()
user, err := service.Store.CreateUser(ctx, &store.User{
Username: username,
Role: role,
Email: username + "@example.com",
})
require.NoError(t, err)
return user
}
func inviteSpaceTestUser(ctx context.Context, t *testing.T, service *APIV1Service, inviter, invitee *store.User, space *v1pb.Space, role v1pb.SpaceMember_Role) *v1pb.SpaceInvitation {
t.Helper()
invitation, err := service.CreateSpaceInvitation(userCtx(ctx, inviter.ID), &v1pb.CreateSpaceInvitationRequest{
Parent: space.Name,
SpaceInvitation: &v1pb.SpaceInvitation{
Invitee: BuildUserName(invitee.Username),
Role: role,
},
})
require.NoError(t, err)
return invitation
}
func inviteAndAcceptSpaceTestUser(ctx context.Context, t *testing.T, service *APIV1Service, inviter, invitee *store.User, space *v1pb.Space, role v1pb.SpaceMember_Role) *v1pb.SpaceMember {
t.Helper()
invitation := inviteSpaceTestUser(ctx, t, service, inviter, invitee, space, role)
membership, err := service.AcceptSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
return membership
}
func TestSpaceServiceMembershipVisibilityAndGovernance(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)
owner := createSpaceTestUser(ctx, t, service, "space-owner", store.RoleUser)
member := createSpaceTestUser(ctx, t, service, "space-member", store.RoleUser)
applicationAdmin := createSpaceTestUser(ctx, t, service, "application-admin", store.RoleAdmin)
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: "team-notes",
Space: &v1pb.Space{Title: " Team notes ", Description: " Shared work "},
})
require.NoError(t, err)
require.Equal(t, "spaces/team-notes", space.Name)
require.Equal(t, "Team notes", space.Title)
_, err = service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: "team-notes",
Space: &v1pb.Space{Title: "Duplicate"},
})
require.Equal(t, codes.AlreadyExists, status.Code(err))
members, err := service.ListSpaceMembers(userCtx(ctx, owner.ID), &v1pb.ListSpaceMembersRequest{Parent: space.Name})
require.NoError(t, err)
require.Len(t, members.SpaceMembers, 1)
require.Equal(t, v1pb.SpaceMember_ADMIN, members.SpaceMembers[0].Role)
_, err = service.GetSpace(userCtx(ctx, applicationAdmin.ID), &v1pb.GetSpaceRequest{Name: space.Name})
require.Equal(t, codes.NotFound, status.Code(err), "application ADMIN must not bypass space membership")
adminSpaces, err := service.ListSpaces(userCtx(ctx, applicationAdmin.ID), &v1pb.ListSpacesRequest{})
require.NoError(t, err)
require.Empty(t, adminSpaces.Spaces)
invitation, err := service.CreateSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.CreateSpaceInvitationRequest{
Parent: space.Name,
SpaceInvitation: &v1pb.SpaceInvitation{Invitee: BuildUserName(member.Username), Role: v1pb.SpaceMember_USER},
})
require.NoError(t, err)
require.Equal(t, "spaces/team-notes/invitations/space-member", invitation.Name)
require.Equal(t, space, invitation.Space, "the invitation must identify the Space without granting membership access")
_, err = service.GetSpace(userCtx(ctx, member.ID), &v1pb.GetSpaceRequest{Name: space.Name})
require.Equal(t, codes.NotFound, status.Code(err), "a pending invitation must not grant Space access")
invitedUserSpaces, err := service.ListSpaces(userCtx(ctx, member.ID), &v1pb.ListSpacesRequest{})
require.NoError(t, err)
require.Empty(t, invitedUserSpaces.Spaces)
members, err = service.ListSpaceMembers(userCtx(ctx, owner.ID), &v1pb.ListSpaceMembersRequest{Parent: space.Name})
require.NoError(t, err)
require.Len(t, members.SpaceMembers, 1, "an invitation must not appear as an active membership")
spaceInvitations, err := service.ListSpaceInvitations(userCtx(ctx, owner.ID), &v1pb.ListSpaceInvitationsRequest{Parent: space.Name})
require.NoError(t, err)
require.Equal(t, []*v1pb.SpaceInvitation{invitation}, spaceInvitations.SpaceInvitations)
userInvitations, err := service.ListUserSpaceInvitations(userCtx(ctx, member.ID), &v1pb.ListUserSpaceInvitationsRequest{Parent: BuildUserName(member.Username)})
require.NoError(t, err)
require.Equal(t, []*v1pb.SpaceInvitation{invitation}, userInvitations.SpaceInvitations)
_, err = service.CreateSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.CreateSpaceInvitationRequest{
Parent: space.Name,
SpaceInvitation: &v1pb.SpaceInvitation{Invitee: BuildUserName(member.Username), Role: v1pb.SpaceMember_USER},
})
require.Equal(t, codes.AlreadyExists, status.Code(err))
_, err = service.AcceptSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err), "an administrator cannot accept for the invitee")
createdMember, err := service.AcceptSpaceInvitation(userCtx(ctx, member.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
require.Equal(t, "spaces/team-notes/members/space-member", createdMember.Name)
require.Equal(t, v1pb.SpaceMember_USER, createdMember.Role)
_, err = service.GetSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.GetSpaceInvitationRequest{Name: invitation.Name})
require.Equal(t, codes.NotFound, status.Code(err))
_, err = service.GetSpace(userCtx(ctx, member.ID), &v1pb.GetSpaceRequest{Name: space.Name})
require.NoError(t, err)
_, err = service.UpdateSpace(userCtx(ctx, member.ID), &v1pb.UpdateSpaceRequest{
Space: &v1pb.Space{Name: space.Name, Title: "not allowed"},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title"}},
})
require.Equal(t, codes.PermissionDenied, status.Code(err))
_, err = service.UpdateSpaceMember(userCtx(ctx, owner.ID), &v1pb.UpdateSpaceMemberRequest{
SpaceMember: &v1pb.SpaceMember{Name: "spaces/team-notes/members/space-owner", Role: v1pb.SpaceMember_USER},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"role"}},
})
require.Equal(t, codes.FailedPrecondition, status.Code(err), "the last active ADMIN cannot be demoted")
}
func TestSpaceInvitationDeclineRevokeAndSelectedRole(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)
owner := createSpaceTestUser(ctx, t, service, "invitation-owner", store.RoleUser)
invitee := createSpaceTestUser(ctx, t, service, "invitation-invitee", store.RoleUser)
secondInvitee := createSpaceTestUser(ctx, t, service, "invitation-second", store.RoleUser)
archivedInvitee := createSpaceTestUser(ctx, t, service, "invitation-archived", store.RoleUser)
pendingThenArchived := createSpaceTestUser(ctx, t, service, "invitation-pending-archived", store.RoleUser)
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: "invitation-lifecycle",
Space: &v1pb.Space{Title: "Invitation lifecycle"},
})
require.NoError(t, err)
invitation := inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_ADMIN)
got, err := service.GetSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.GetSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
require.Equal(t, v1pb.SpaceMember_ADMIN, got.Role)
_, err = service.DeleteSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.DeleteSpaceInvitationRequest{Name: invitation.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err), "the invitee cannot revoke an invitation")
_, err = service.DeleteSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.DeleteSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
_, err = service.AcceptSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
require.Equal(t, codes.NotFound, status.Code(err), "a revoked invitation cannot be accepted")
invitation = inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_ADMIN)
_, err = service.DeclineSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.DeclineSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
_, err = service.GetSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.GetSpaceInvitationRequest{Name: invitation.Name})
require.Equal(t, codes.NotFound, status.Code(err))
invitation = inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_ADMIN)
membership, err := service.AcceptSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
require.Equal(t, v1pb.SpaceMember_ADMIN, membership.Role, "accept must preserve the role selected at invite time")
secondInvitation := inviteSpaceTestUser(ctx, t, service, owner, secondInvitee, space, v1pb.SpaceMember_USER)
listed, err := service.ListSpaceInvitations(userCtx(ctx, invitee.ID), &v1pb.ListSpaceInvitationsRequest{Parent: space.Name})
require.NoError(t, err, "any active Space administrator may manage invitations")
require.Equal(t, []*v1pb.SpaceInvitation{secondInvitation}, listed.SpaceInvitations)
_, err = service.AcceptSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.AcceptSpaceInvitationRequest{Name: secondInvitation.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err), "an administrator cannot accept for another user")
_, err = service.DeleteSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.DeleteSpaceInvitationRequest{Name: secondInvitation.Name})
require.NoError(t, err)
pendingArchivedInvitation := inviteSpaceTestUser(ctx, t, service, owner, pendingThenArchived, space, v1pb.SpaceMember_USER)
archived := store.Archived
_, err = service.Store.UpdateUser(ctx, &store.UpdateUser{ID: pendingThenArchived.ID, RowStatus: &archived})
require.NoError(t, err, "a pending invitation is not an active membership and must not prevent archival")
listed, err = service.ListSpaceInvitations(userCtx(ctx, owner.ID), &v1pb.ListSpaceInvitationsRequest{Parent: space.Name})
require.NoError(t, err)
require.Equal(t, []*v1pb.SpaceInvitation{pendingArchivedInvitation}, listed.SpaceInvitations, "administrators must retain a way to revoke invitations for archived users")
_, err = service.AcceptSpaceInvitation(userCtx(ctx, pendingThenArchived.ID), &v1pb.AcceptSpaceInvitationRequest{Name: pendingArchivedInvitation.Name})
require.Equal(t, codes.Unauthenticated, status.Code(err), "an archived user cannot accept an invitation")
_, err = service.DeleteSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.DeleteSpaceInvitationRequest{Name: pendingArchivedInvitation.Name})
require.NoError(t, err)
_, err = service.Store.UpdateUser(ctx, &store.UpdateUser{ID: archivedInvitee.ID, RowStatus: &archived})
require.NoError(t, err)
_, err = service.CreateSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.CreateSpaceInvitationRequest{
Parent: space.Name,
SpaceInvitation: &v1pb.SpaceInvitation{Invitee: BuildUserName(archivedInvitee.Username), Role: v1pb.SpaceMember_USER},
})
require.Equal(t, codes.FailedPrecondition, status.Code(err), "only NORMAL users can be invited")
}
func TestSpaceServiceHardDeleteLifecycle(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)
owner := createSpaceTestUser(ctx, t, service, "delete-owner", store.RoleUser)
target := createSpaceTestUser(ctx, t, service, "delete-target", store.RoleUser)
applicationAdmin := createSpaceTestUser(ctx, t, service, "delete-application-admin", store.RoleAdmin)
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: "delete-space",
Space: &v1pb.Space{Title: "Delete me"},
})
require.NoError(t, err)
spaceUID, err := ExtractSpaceUIDFromName(space.Name)
require.NoError(t, err)
storedSpace, err := service.Store.GetSpace(ctx, &store.FindSpace{UID: &spaceUID})
require.NoError(t, err)
require.NotNil(t, storedSpace)
assignedMemo, err := service.Store.CreateMemo(ctx, &store.Memo{
UID: "delete-space-memo",
CreatorID: owner.ID,
Content: "delete with Space",
Visibility: store.Private,
SpaceID: &storedSpace.ID,
})
require.NoError(t, err)
attachmentPath := filepath.Join(t.TempDir(), "space-delete-attachment.txt")
require.NoError(t, os.WriteFile(attachmentPath, []byte("delete me"), 0o600))
_, err = service.Store.CreateAttachment(ctx, &store.Attachment{
UID: "delete-space-attachment",
CreatorID: owner.ID,
Filename: "space-delete-attachment.txt",
MemoID: &assignedMemo.ID,
StorageType: storepb.AttachmentStorageType_LOCAL,
Reference: attachmentPath,
Payload: &storepb.AttachmentPayload{},
})
require.NoError(t, err)
inviteAndAcceptSpaceTestUser(ctx, t, service, owner, target, space, v1pb.SpaceMember_USER)
_, err = service.UpdateSpace(userCtx(ctx, owner.ID), &v1pb.UpdateSpaceRequest{
Space: &v1pb.Space{Name: space.Name},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"state"}},
})
require.Equal(t, codes.InvalidArgument, status.Code(err), "Space has no archive or restore state")
_, err = service.DeleteSpace(userCtx(ctx, target.ID), &v1pb.DeleteSpaceRequest{Name: space.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err))
_, err = service.DeleteSpace(userCtx(ctx, applicationAdmin.ID), &v1pb.DeleteSpaceRequest{Name: space.Name})
require.Equal(t, codes.NotFound, status.Code(err), "application ADMIN must not bypass Space membership")
_, err = service.DeleteSpace(userCtx(ctx, owner.ID), &v1pb.DeleteSpaceRequest{Name: space.Name})
require.NoError(t, err)
require.NoFileExists(t, attachmentPath)
_, err = service.GetSpace(userCtx(ctx, owner.ID), &v1pb.GetSpaceRequest{Name: space.Name})
require.Equal(t, codes.NotFound, status.Code(err))
spaces, err := service.ListSpaces(userCtx(ctx, owner.ID), &v1pb.ListSpacesRequest{})
require.NoError(t, err)
require.Empty(t, spaces.Spaces)
}
func TestSpaceServiceHidesMembershipForInactiveUser(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)
owner := createSpaceTestUser(ctx, t, service, "inactive-member-owner", store.RoleUser)
member := createSpaceTestUser(ctx, t, service, "inactive-member", store.RoleUser)
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: "inactive-member-space",
Space: &v1pb.Space{Title: "Inactive member space"},
})
require.NoError(t, err)
inviteAndAcceptSpaceTestUser(ctx, t, service, owner, member, space, v1pb.SpaceMember_USER)
// The normal archive path rejects users with memberships. Simulate an
// inconsistent row to verify that membership reads still fail closed.
_, err = service.Store.GetDriver().GetDB().ExecContext(ctx, "UPDATE user SET row_status = 'ARCHIVED' WHERE id = ?", member.ID)
require.NoError(t, err)
members, err := service.ListSpaceMembers(userCtx(ctx, owner.ID), &v1pb.ListSpaceMembersRequest{Parent: space.Name})
require.NoError(t, err)
require.Len(t, members.SpaceMembers, 1)
require.Equal(t, BuildUserName(owner.Username), members.SpaceMembers[0].User)
_, err = service.GetSpaceMember(userCtx(ctx, owner.ID), &v1pb.GetSpaceMemberRequest{
Name: buildSpaceMemberName("inactive-member-space", member.Username),
})
require.Equal(t, codes.NotFound, status.Code(err))
}
func TestUpdateSpaceMemberAcceptsGatewayInferredIdentityMask(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)
owner := createSpaceTestUser(ctx, t, service, "gateway-owner", store.RoleUser)
member := createSpaceTestUser(ctx, t, service, "gateway-member", store.RoleUser)
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: "gateway-space",
Space: &v1pb.Space{Title: "Gateway Space"},
})
require.NoError(t, err)
createdMember := inviteAndAcceptSpaceTestUser(ctx, t, service, owner, member, space, v1pb.SpaceMember_USER)
mux := runtime.NewServeMux()
require.NoError(t, v1pb.RegisterSpaceServiceHandlerServer(ctx, mux, service))
patchMember := func(body string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPatch, "/api/v1/"+createdMember.Name, strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request = request.WithContext(userCtx(request.Context(), owner.ID))
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
return response
}
response := patchMember(`{"user":"users/gateway-member","role":"ADMIN"}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
spaceUID, err := ExtractSpaceUIDFromName(space.Name)
require.NoError(t, err)
storedSpace, err := service.Store.GetSpace(ctx, &store.FindSpace{UID: &spaceUID})
require.NoError(t, err)
require.NotNil(t, storedSpace)
storedMember, err := service.Store.GetSpaceMember(ctx, &store.FindSpaceMember{SpaceID: &storedSpace.ID, UserID: &member.ID})
require.NoError(t, err)
require.NotNil(t, storedMember)
require.Equal(t, store.SpaceMemberRoleAdmin, storedMember.Role)
response = patchMember(`{"user":"users/gateway-owner","role":"USER"}`)
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
storedMember, err = service.Store.GetSpaceMember(ctx, &store.FindSpaceMember{SpaceID: &storedSpace.ID, UserID: &member.ID})
require.NoError(t, err)
require.NotNil(t, storedMember)
require.Equal(t, store.SpaceMemberRoleAdmin, storedMember.Role, "a mismatched immutable user must not update the membership")
}
func TestCreateSpaceInvitationClassifiesTargetUserErrors(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)
owner := createSpaceTestUser(ctx, t, service, "lookup-owner", store.RoleUser)
target := createSpaceTestUser(ctx, t, service, "lookup-target", store.RoleUser)
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: "lookup-space",
Space: &v1pb.Space{Title: "Lookup Space"},
})
require.NoError(t, err)
_, err = service.CreateSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.CreateSpaceInvitationRequest{
Parent: space.Name,
SpaceInvitation: &v1pb.SpaceInvitation{Invitee: "invalid-user-name", Role: v1pb.SpaceMember_USER},
})
require.Equal(t, codes.InvalidArgument, status.Code(err))
// SQLite permits a text value in this integer column. Corrupt only the
// target row so authentication and Space authorization still succeed, then
// require the target lookup failure to remain a server error.
_, err = service.Store.GetDriver().GetDB().ExecContext(ctx, "UPDATE user SET created_ts = ? WHERE id = ?", "not-an-integer", target.ID)
require.NoError(t, err)
_, err = service.CreateSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.CreateSpaceInvitationRequest{
Parent: space.Name,
SpaceInvitation: &v1pb.SpaceInvitation{Invitee: BuildUserName(target.Username), Role: v1pb.SpaceMember_USER},
})
require.Equal(t, codes.Internal, status.Code(err))
}