Files
moteva/server/internal/modules/sharing/service_test.go
T

284 lines
8.8 KiB
Go

package sharing
import (
"context"
"errors"
"strings"
"sync"
"testing"
)
const (
testOwnerID = "91cd197b-8255-4172-9981-77391fd38f33"
testMemberID = "ea4dc900-39a1-45e7-a652-2430499f1a5f"
)
func newTestService(t *testing.T) (*Service, *MemoryStore) {
t.Helper()
store := NewMemoryStore()
service, err := NewService(store, "test-sharing-secret")
if err != nil {
t.Fatal(err)
}
return service, store
}
func testOwnerActor() Actor {
return Actor{Authenticated: true, UserID: testOwnerID}
}
func TestResolveAccessUsesMemberBeforeLink(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
settings, err := service.UpdateLink(ctx, "project-1", testOwnerID, PermissionCanvas)
if err != nil {
t.Fatal(err)
}
if len(settings.ShareID) != shareIDLength || !shareIDPattern.MatchString(settings.ShareID) {
t.Fatalf("expected a %d-character case-sensitive base62 id, got %q", shareIDLength, settings.ShareID)
}
if _, err := service.Invite(ctx, "project-1", testOwnerActor(), "Editor@Example.com", PermissionEditor); err != nil {
t.Fatal(err)
}
linkAccess, err := service.ResolveAccess(ctx, settings.ShareID, Actor{})
if err != nil {
t.Fatal(err)
}
if linkAccess.Permission != PermissionCanvas || linkAccess.Capabilities.CanViewChat || linkAccess.Capabilities.CanEdit {
t.Fatalf("unexpected anonymous canvas access: %+v", linkAccess)
}
memberAccess, err := service.ResolveAccess(ctx, settings.ShareID, Actor{
Authenticated: true,
UserID: testMemberID,
Email: "editor@example.com",
})
if err != nil {
t.Fatal(err)
}
if memberAccess.Source != "member" || memberAccess.Permission != PermissionEditor || !memberAccess.Capabilities.CanEdit {
t.Fatalf("expected explicit editor access, got %+v", memberAccess)
}
}
func TestEditorLinkRequiresAuthentication(t *testing.T) {
service, _ := newTestService(t)
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionEditor)
if err != nil {
t.Fatal(err)
}
if _, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{}); !errors.Is(err, ErrLoginRequired) {
t.Fatalf("expected login required, got %v", err)
}
access, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{Authenticated: true, UserID: testMemberID})
if err != nil {
t.Fatal(err)
}
if !access.Capabilities.CanEdit || access.Source != "link" {
t.Fatalf("expected authenticated link editor, got %+v", access)
}
}
func TestRevokeAllInvalidatesTokenAndMembers(t *testing.T) {
service, _ := newTestService(t)
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer)
if err != nil {
t.Fatal(err)
}
if _, err := service.Invite(context.Background(), "project-1", testOwnerActor(), "+8613800138000", PermissionEditor); err != nil {
t.Fatal(err)
}
if _, err := service.RevokeAll(context.Background(), "project-1", testOwnerID); err != nil {
t.Fatal(err)
}
if _, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{}); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected revoked link to be missing, got %v", err)
}
privateSettings, err := service.Settings(context.Background(), "project-1", testOwnerID)
if err != nil {
t.Fatal(err)
}
if privateSettings.ShareID != "" || privateSettings.LinkPermission != PermissionPrivate || len(privateSettings.Members) != 0 {
t.Fatalf("expected private empty settings, got %+v", privateSettings)
}
}
func TestShareIDIsCaseSensitive(t *testing.T) {
service, _ := newTestService(t)
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer)
if err != nil {
t.Fatal(err)
}
mutated := []byte(settings.ShareID)
for index, value := range mutated {
if value >= 'a' && value <= 'z' {
mutated[index] = value - ('a' - 'A')
break
}
if value >= 'A' && value <= 'Z' {
mutated[index] = value + ('a' - 'A')
break
}
}
if string(mutated) == settings.ShareID {
t.Skip("random token contained no letters")
}
if _, err := service.ResolveAccess(context.Background(), string(mutated), Actor{}); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected case-mutated token to fail, got %v", err)
}
}
func TestSensitiveValuesAreEncryptedAtRest(t *testing.T) {
service, store := newTestService(t)
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionCanvas)
if err != nil {
t.Fatal(err)
}
if _, err := service.Invite(context.Background(), "project-1", testOwnerActor(), "private@example.com", PermissionViewer); err != nil {
t.Fatal(err)
}
policy := store.policies["project-1"]
if strings.Contains(string(policy.TokenCiphertext), settings.ShareID) {
t.Fatal("share id must not be stored in plaintext")
}
for _, member := range store.members["project-1"] {
if strings.Contains(string(member.IdentifierCiphertext), "private@example.com") {
t.Fatal("member identifier must not be stored in plaintext")
}
}
}
func TestRecordVisitDeduplicatesVisitor(t *testing.T) {
service, _ := newTestService(t)
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer)
if err != nil {
t.Fatal(err)
}
access, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{})
if err != nil {
t.Fatal(err)
}
if err := service.RecordVisit(context.Background(), access, Actor{}, "visitor-1234567890"); err != nil {
t.Fatal(err)
}
if err := service.RecordVisit(context.Background(), access, Actor{}, "visitor-1234567890"); err != nil {
t.Fatal(err)
}
visitors, err := service.Visitors(context.Background(), "project-1", testOwnerID)
if err != nil {
t.Fatal(err)
}
if len(visitors) != 1 || visitors[0].VisitCount != 2 {
t.Fatalf("expected one repeat visitor, got %+v", visitors)
}
}
func TestInviteRejectsOwnerIdentifiers(t *testing.T) {
tests := []struct {
name string
identifier string
}{
{name: "email is case insensitive", identifier: " OWNER@EXAMPLE.COM "},
{name: "local phone is normalized", identifier: "138-0013-8000"},
{name: "international phone is normalized", identifier: "+86 138 0013 8000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, store := newTestService(t)
owner := Actor{
Authenticated: true,
UserID: testOwnerID,
Email: "owner@example.com",
CountryCode: "+86",
Phone: "13800138000",
}
if _, err := service.Invite(context.Background(), "project-1", owner, tt.identifier, PermissionViewer); !errors.Is(err, ErrSelfInvite) {
t.Fatalf("expected self-invite rejection, got %v", err)
}
if len(store.members["project-1"]) != 0 {
t.Fatal("self-invite must not create a member")
}
})
}
}
func TestInviteRejectsNormalizedDuplicateWithoutChangingPermission(t *testing.T) {
tests := []struct {
name string
first string
duplicate string
}{
{name: "email", first: "Member@Example.com", duplicate: " member@example.com "},
{name: "phone", first: "+86 139-0013-9000", duplicate: "+8613900139000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, store := newTestService(t)
ctx := context.Background()
owner := testOwnerActor()
if _, err := service.Invite(ctx, "project-1", owner, tt.first, PermissionViewer); err != nil {
t.Fatal(err)
}
if _, err := service.Invite(ctx, "project-1", owner, tt.duplicate, PermissionEditor); !errors.Is(err, ErrMemberExists) {
t.Fatalf("expected duplicate member rejection, got %v", err)
}
members := store.members["project-1"]
if len(members) != 1 {
t.Fatalf("expected one member, got %d", len(members))
}
for _, member := range members {
if member.Permission != PermissionViewer {
t.Fatalf("duplicate invite changed permission to %q", member.Permission)
}
}
})
}
}
func TestInviteCreatesOneMemberUnderConcurrency(t *testing.T) {
service, store := newTestService(t)
owner := testOwnerActor()
const attempts = 12
start := make(chan struct{})
errorsByAttempt := make(chan error, attempts)
var workers sync.WaitGroup
for index := 0; index < attempts; index++ {
workers.Add(1)
go func() {
defer workers.Done()
<-start
_, err := service.Invite(context.Background(), "project-1", owner, "concurrent@example.com", PermissionViewer)
errorsByAttempt <- err
}()
}
close(start)
workers.Wait()
close(errorsByAttempt)
successes := 0
duplicates := 0
for err := range errorsByAttempt {
switch {
case err == nil:
successes++
case errors.Is(err, ErrMemberExists):
duplicates++
default:
t.Fatalf("unexpected concurrent invite error: %v", err)
}
}
if successes != 1 || duplicates != attempts-1 {
t.Fatalf("expected one success and %d duplicates, got %d and %d", attempts-1, successes, duplicates)
}
if len(store.members["project-1"]) != 1 {
t.Fatalf("expected exactly one stored member, got %d", len(store.members["project-1"]))
}
}