package services import ( "context" "strings" "testing" "time" "clawreef/internal/models" ) func TestInstanceExternalAccessShareLinkValidation(t *testing.T) { repo := newFakeExternalAccessRepo() service := NewInstanceExternalAccessService(repo) result, err := service.EnableShareLink(context.Background(), 42, 7, ExternalAccessExpirationRequest{Mode: ExternalAccessExpirationPermanent}) if err != nil { t.Fatalf("EnableShareLink failed: %v", err) } if !strings.HasPrefix(result.ShareURL, "/s/") || strings.Contains(result.ShareURL, "token=") || strings.Contains(result.ShareURL, "/api/v1/") || strings.Contains(result.ShareURL, "/proxy") { t.Fatalf("unexpected short share URL: %q", result.ShareURL) } code := strings.Trim(strings.TrimPrefix(result.ShareURL, "/s/"), "/") if code == "" { t.Fatalf("short code missing from URL %q", result.ShareURL) } if result.Access.ShortCodeHash == nil || *result.Access.ShortCodeHash == code { t.Fatalf("expected stored short code to be hashed") } if result.Access.AuthMode != ExternalAccessModeShareLink { t.Fatalf("auth mode = %q, want %q", result.Access.AuthMode, ExternalAccessModeShareLink) } access, err := service.ValidateShortLink(context.Background(), code, "") if err != nil { t.Fatalf("ValidateShortLink failed: %v", err) } if access.InstanceID != 42 { t.Fatalf("validated instance ID = %d, want 42", access.InstanceID) } if repo.markUsedCount != 1 { t.Fatalf("mark used count = %d, want 1", repo.markUsedCount) } if _, err := service.ValidateShortLink(context.Background(), "wrong-code", ""); err == nil { t.Fatalf("expected wrong short code to fail") } } func TestInstanceExternalAccessShareLinkPresetExpiration(t *testing.T) { repo := newFakeExternalAccessRepo() service := NewInstanceExternalAccessService(repo) before := time.Now().UTC().Add(24 * time.Hour) result, err := service.EnableShareLink(context.Background(), 42, 7, ExternalAccessExpirationRequest{ Mode: ExternalAccessExpirationPreset, Preset: ExternalAccessPreset24Hours, }) if err != nil { t.Fatalf("EnableShareLink failed: %v", err) } if result.Access.ExpiresAt == nil { t.Fatal("preset expiration must set expires_at") } after := time.Now().UTC().Add(24 * time.Hour) if result.Access.ExpiresAt.Before(before) || result.Access.ExpiresAt.After(after.Add(time.Second)) { t.Fatalf("preset expires_at = %v, want around 24h from now", result.Access.ExpiresAt) } } func TestInstanceExternalAccessShareLinkCustomAndPermanentExpiration(t *testing.T) { repo := newFakeExternalAccessRepo() service := NewInstanceExternalAccessService(repo) customAt := time.Now().UTC().Add(3 * time.Hour).Truncate(time.Second) custom, err := service.EnableShareLink(context.Background(), 42, 7, ExternalAccessExpirationRequest{ Mode: ExternalAccessExpirationCustom, ExpiresAt: &customAt, }) if err != nil { t.Fatalf("custom EnableShareLink failed: %v", err) } if custom.Access.ExpiresAt == nil || !custom.Access.ExpiresAt.Equal(customAt) { t.Fatalf("custom expires_at = %v, want %v", custom.Access.ExpiresAt, customAt) } permanent, err := service.EnableShareLink(context.Background(), 42, 7, ExternalAccessExpirationRequest{Mode: ExternalAccessExpirationPermanent}) if err != nil { t.Fatalf("permanent EnableShareLink failed: %v", err) } if permanent.Access.ExpiresAt != nil { t.Fatalf("permanent expires_at = %v, want nil", permanent.Access.ExpiresAt) } } func TestInstanceExternalAccessPasswordDisable(t *testing.T) { repo := newFakeExternalAccessRepo() service := NewInstanceExternalAccessService(repo) result, err := service.CreatePassword(context.Background(), 100, 9, ExternalAccessExpirationRequest{Mode: ExternalAccessExpirationPermanent}) if err != nil { t.Fatalf("CreatePassword failed: %v", err) } if !strings.HasPrefix(result.Password, "pwd_") { t.Fatalf("unexpected password prefix: %q", result.Password) } if result.Access.PasswordHash == nil || *result.Access.PasswordHash == result.Password { t.Fatalf("expected stored password to be hashed") } if result.Access.PasswordHint == nil || !strings.HasPrefix(result.Password, *result.Access.PasswordHint) { t.Fatalf("stored password hint does not match generated password") } if result.Access.AuthMode != ExternalAccessModePassword { t.Fatalf("auth mode = %q, want %q", result.Access.AuthMode, ExternalAccessModePassword) } if !strings.HasPrefix(result.ShareURL, "/s/") || strings.Contains(result.ShareURL, "token=") || strings.Contains(result.ShareURL, "/api/v1/") { t.Fatalf("unexpected password short URL: %q", result.ShareURL) } code := strings.Trim(strings.TrimPrefix(result.ShareURL, "/s/"), "/") if _, err := service.ValidateShortLink(context.Background(), code, result.Password); err != nil { t.Fatalf("ValidateShortLink with password failed: %v", err) } if err := service.Disable(context.Background(), 100); err != nil { t.Fatalf("Disable failed: %v", err) } if _, err := service.ValidateShortLink(context.Background(), code, result.Password); err == nil { t.Fatalf("expected disabled password access to fail") } } func TestInstanceExternalAccessRestoresShareURLAfterRefresh(t *testing.T) { repo := newFakeExternalAccessRepo() service := NewInstanceExternalAccessService(repo) result, err := service.CreatePassword(context.Background(), 100, 9, ExternalAccessExpirationRequest{Mode: ExternalAccessExpirationPermanent}) if err != nil { t.Fatalf("CreatePassword failed: %v", err) } reloaded, err := service.Get(context.Background(), 100) if err != nil { t.Fatalf("Get failed: %v", err) } if got := ExternalAccessShareURL(reloaded); got != result.ShareURL { t.Fatalf("ExternalAccessShareURL after reload = %q, want %q", got, result.ShareURL) } } func TestInstanceExternalAccessRestoresPasswordAfterRefresh(t *testing.T) { repo := newFakeExternalAccessRepo() service := NewInstanceExternalAccessService(repo) result, err := service.CreatePassword(context.Background(), 100, 9, ExternalAccessExpirationRequest{Mode: ExternalAccessExpirationPermanent}) if err != nil { t.Fatalf("CreatePassword failed: %v", err) } reloaded, err := service.Get(context.Background(), 100) if err != nil { t.Fatalf("Get failed: %v", err) } if got := ExternalAccessPassword(reloaded); got != result.Password { t.Fatalf("ExternalAccessPassword after reload = %q, want generated password", got) } } func TestInstanceExternalAccessExpiration(t *testing.T) { repo := newFakeExternalAccessRepo() service := NewInstanceExternalAccessService(repo) expiredAt := time.Now().UTC().Add(-time.Minute) result, err := service.EnableShareLink(context.Background(), 77, 3, ExternalAccessExpirationRequest{ Mode: ExternalAccessExpirationCustom, ExpiresAt: &expiredAt, }) if err != nil { t.Fatalf("EnableShareLink failed: %v", err) } code := strings.Trim(strings.TrimPrefix(result.ShareURL, "/s/"), "/") if _, err := service.ValidateShortLink(context.Background(), code, ""); err == nil { t.Fatalf("expected expired public access to fail") } } type fakeExternalAccessRepo struct { byInstance map[int]*models.InstanceExternalAccess byCodeHash map[string]*models.InstanceExternalAccess nextID int64 markUsedCount int } func newFakeExternalAccessRepo() *fakeExternalAccessRepo { return &fakeExternalAccessRepo{ byInstance: make(map[int]*models.InstanceExternalAccess), byCodeHash: make(map[string]*models.InstanceExternalAccess), nextID: 1, } } func (r *fakeExternalAccessRepo) GetByInstanceID(ctx context.Context, instanceID int) (*models.InstanceExternalAccess, error) { _ = ctx return cloneExternalAccess(r.byInstance[instanceID]), nil } func (r *fakeExternalAccessRepo) GetByShortCodeHash(ctx context.Context, codeHash string) (*models.InstanceExternalAccess, error) { _ = ctx return cloneExternalAccess(r.byCodeHash[codeHash]), nil } func (r *fakeExternalAccessRepo) Upsert(ctx context.Context, access *models.InstanceExternalAccess) error { _ = ctx saved := cloneExternalAccess(access) if saved.ID == 0 { if existing := r.byInstance[saved.InstanceID]; existing != nil { saved.ID = existing.ID } else { saved.ID = r.nextID r.nextID++ } } if existing := r.byInstance[saved.InstanceID]; existing != nil && existing.ShortCodeHash != nil { delete(r.byCodeHash, *existing.ShortCodeHash) } r.byInstance[saved.InstanceID] = cloneExternalAccess(saved) if saved.ShortCodeHash != nil { r.byCodeHash[*saved.ShortCodeHash] = cloneExternalAccess(saved) } return nil } func (r *fakeExternalAccessRepo) Disable(ctx context.Context, instanceID int) error { _ = ctx if existing := r.byInstance[instanceID]; existing != nil { existing.Enabled = false if existing.ShortCodeHash != nil { if byCode := r.byCodeHash[*existing.ShortCodeHash]; byCode != nil { byCode.Enabled = false } } } return nil } func (r *fakeExternalAccessRepo) MarkUsed(ctx context.Context, id int64) error { _ = ctx r.markUsedCount++ now := time.Now().UTC() for _, access := range r.byInstance { if access.ID == id { access.LastUsedAt = &now } } for _, access := range r.byCodeHash { if access.ID == id { access.LastUsedAt = &now } } return nil } func cloneExternalAccess(access *models.InstanceExternalAccess) *models.InstanceExternalAccess { if access == nil { return nil } clone := *access if access.PublicSlug != nil { value := *access.PublicSlug clone.PublicSlug = &value } if access.PublicTokenHash != nil { value := *access.PublicTokenHash clone.PublicTokenHash = &value } if access.ShortCodeHash != nil { value := *access.ShortCodeHash clone.ShortCodeHash = &value } if access.PasswordHash != nil { value := *access.PasswordHash clone.PasswordHash = &value } if access.PasswordValue != nil { value := *access.PasswordValue clone.PasswordValue = &value } if access.PasswordHint != nil { value := *access.PasswordHint clone.PasswordHint = &value } if access.ExpiresAt != nil { value := *access.ExpiresAt clone.ExpiresAt = &value } if access.CreatedBy != nil { value := *access.CreatedBy clone.CreatedBy = &value } if access.LastUsedAt != nil { value := *access.LastUsedAt clone.LastUsedAt = &value } return &clone }