284 lines
8.8 KiB
Go
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"]))
|
||
|
|
}
|
||
|
|
}
|