Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
TestingAccessWrapper
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
10 / 10
29
100.00% covered (success)
100.00%
1 / 1
 newFromObject
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 newFromClass
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 constant
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 __call
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 __set
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 __get
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 isStatic
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMethod
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getProperty
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3namespace Wikimedia;
4
5use DomainException;
6use InvalidArgumentException;
7use ReflectionClass;
8use ReflectionException;
9use ReflectionMethod;
10use ReflectionProperty;
11
12/**
13 * Circumvent access restrictions on object internals
14 *
15 * This can be helpful for writing tests that can probe object internals,
16 * without having to modify the class under test to accommodate.
17 *
18 * Wrap an object with private methods as follows:
19 *    $title = TestingAccessWrapper::newFromObject( Title::newFromDBkey( $key ) );
20 *
21 * You can access private and protected instance methods and variables:
22 *    $formatter = $title->getTitleFormatter();
23 *
24 * You can access private and protected constants:
25 *    $value = TestingAccessWrapper::constant( Foo::class, 'FOO_CONSTANT' );
26 *
27 */
28class TestingAccessWrapper {
29    /** @var mixed The object, or the class name for static-only access */
30    public $object;
31
32    /**
33     * Return a proxy object which can be used the same way as the original,
34     * except that access restrictions can be ignored (protected and private methods and properties
35     * are available for any caller).
36     * @param object $object
37     * @return self
38     * @throws InvalidArgumentException
39     */
40    public static function newFromObject( $object ) {
41        if ( !is_object( $object ) ) {
42            throw new InvalidArgumentException( __METHOD__ . ' must be called with an object' );
43        }
44        $wrapper = new self();
45        $wrapper->object = $object;
46        return $wrapper;
47    }
48
49    /**
50     * Allow access to non-public static methods and properties of the class.
51     * Returns an object whose methods/properties will correspond to the
52     * static methods/properties of the given class.
53     * @param class-string $className
54     * @return self
55     * @throws InvalidArgumentException
56     */
57    public static function newFromClass( $className ) {
58        if ( !is_string( $className ) ) {
59            throw new InvalidArgumentException( __METHOD__ . ' must be called with a class name' );
60        }
61        $wrapper = new self();
62        $wrapper->object = $className;
63        return $wrapper;
64    }
65
66    /**
67     * Allow access to non-public constants of the class.
68     * @param class-string $className
69     * @param string $constantName
70     * @return mixed
71     */
72    public static function constant( $className, $constantName ) {
73        $classReflection = new ReflectionClass( $className );
74        // getConstant() returns `false` if the constant is defined in
75        // a parent class; this works more like ReflectionClass::getMethod()
76        while ( !$classReflection->hasConstant( $constantName ) ) {
77            $classReflection = $classReflection->getParentClass();
78            if ( !$classReflection ) {
79                throw new ReflectionException( 'constant not present' );
80            }
81        }
82        return $classReflection->getConstant( $constantName );
83    }
84
85    /**
86     * Allow constructing a class with a non-public constructor.
87     * @param class-string<T> $className
88     * @param mixed ...$args
89     * @return T
90     * @phan-template T
91     */
92    public static function construct( string $className, ...$args ) {
93        $classReflection = new ReflectionClass( $className );
94        $constructor = $classReflection->getConstructor();
95        $constructor->setAccessible( true );
96        $object = $classReflection->newInstanceWithoutConstructor();
97        $constructor->invokeArgs( $object, $args );
98        return $object;
99    }
100
101    /**
102     * @param string $method
103     * @param array $args
104     * @return mixed
105     */
106    public function __call( $method, $args ) {
107        $methodReflection = $this->getMethod( $method );
108
109        if ( $this->isStatic() && !$methodReflection->isStatic() ) {
110            throw new DomainException( __METHOD__
111                . ': Cannot call non-static method when wrapping static class' );
112        }
113
114        return $methodReflection->invokeArgs( $methodReflection->isStatic() ? null : $this->object,
115            $args );
116    }
117
118    /**
119     * @param string $name
120     * @param mixed $value
121     */
122    public function __set( $name, $value ) {
123        $propertyReflection = $this->getProperty( $name );
124
125        if ( $this->isStatic() && !$propertyReflection->isStatic() ) {
126            throw new DomainException( __METHOD__
127                . ': Cannot set non-static property when wrapping static class' );
128        }
129
130        if ( $this->isStatic() ) {
131            $class = new ReflectionClass( $this->object );
132            $class->setStaticPropertyValue( $name, $value );
133        } else {
134            $propertyReflection->setValue( $this->object, $value );
135        }
136    }
137
138    /**
139     * @param string $name Field name
140     * @return mixed
141     */
142    public function __get( $name ) {
143        $propertyReflection = $this->getProperty( $name );
144
145        if ( $this->isStatic() && !$propertyReflection->isStatic() ) {
146            throw new DomainException( __METHOD__
147                . ': Cannot get non-static property when wrapping static class' );
148        }
149
150        if ( $propertyReflection->isStatic() ) {
151            // https://bugs.php.net/bug.php?id=69804 - can't use getStaticPropertyValue() on
152            // non-public properties
153            $class = new ReflectionClass( $this->object );
154            $props = $class->getStaticProperties();
155
156            // Can't use isset() as it returns false for null values
157            if ( !array_key_exists( $name, $props ) ) {
158                throw new DomainException( __METHOD__ . ": class {$class->name} "
159                    . "doesn't have static property '{$name}'" );
160            }
161            return $props[$name];
162        }
163
164        return $propertyReflection->getValue( $this->object );
165    }
166
167    /**
168     * Tells whether this object was created for an object or a class.
169     * @return bool
170     */
171    private function isStatic() {
172        return is_string( $this->object );
173    }
174
175    /**
176     * Return a method and make it accessible.
177     * @param string $name
178     * @return ReflectionMethod
179     * @throws ReflectionException
180     */
181    private function getMethod( $name ) {
182        $classReflection = new ReflectionClass( $this->object );
183        $methodReflection = $classReflection->getMethod( $name );
184        $methodReflection->setAccessible( true );
185        return $methodReflection;
186    }
187
188    /**
189     * Return a property and make it accessible.
190     *
191     * ReflectionClass::getProperty() fails if the private property is defined
192     * in a parent class. This works more like ReflectionClass::getMethod().
193     *
194     * @param string $name
195     * @return ReflectionProperty
196     * @throws ReflectionException
197     */
198    private function getProperty( $name ) {
199        $classReflection = new ReflectionClass( $this->object );
200        try {
201            $propertyReflection = $classReflection->getProperty( $name );
202        } catch ( ReflectionException $ex ) {
203            while ( true ) {
204                $classReflection = $classReflection->getParentClass();
205                if ( !$classReflection ) {
206                    throw $ex;
207                }
208                try {
209                    $propertyReflection = $classReflection->getProperty( $name );
210                } catch ( ReflectionException $ex2 ) {
211                    continue;
212                }
213                if ( $propertyReflection->isPrivate() ) {
214                    break;
215                } else {
216                    // @codeCoverageIgnoreStart
217                    throw $ex;
218                    // @codeCoverageIgnoreEnd
219                }
220            }
221        }
222        $propertyReflection->setAccessible( true );
223        return $propertyReflection;
224    }
225}