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"])) } }