feat(server): add project sharing access control
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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"]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user