diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..6aac9a0efe1869a89405108040bfe493a24bfc8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea/ +/vendor/ +/build/ +.phpunit.result.cache \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..8000a6faacf471c537530805ab29523c7732e11a --- /dev/null +++ b/LICENSE @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + <one line to give the library's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random + Hacker. + + <signature of Ty Coon>, 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/README.md b/README.md index 545213a4ab64c99aa81d6ee52bcb8927994ae005..c623a6fee7ee27d21cfc5f3bc75bade6f02a0852 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,124 @@ -# simpleSAMLphp +# simplesamlphp-module-accounting +SimpleSAMLphp module providing user accounting functionality using SimpleSAMLphp authentication processing +filters feature. + +## Features +- Enables tracking of authentication events, synchronously (during authentication event) or +asynchronously (in a separate process using SimpleSAMLphp Cron feature) +- Provides endpoints for end users to check their personal data, summary on connected +Service Providers, and list of authentication events +- Comes with default [DBAL backend storage](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/index.html), +meaning the following database vendors can be used: MySQL, Oracle, Microsoft SQL Server, PostgreSQL, SQLite. Other +backend storages can be added by following proper interfaces. +- Comes with setup procedure which sets up backend storage. In case of Doctrine DBAL this means running SQL migrations +which create proper tables in configured database. +- Each backend storage connection can have master and slave configuration (master for writing, slave for reading) +- Has "trackers" which persist authentication data to backend storage. Currently, there is one default Doctrine DBAL +compatible tracker which stores authentication events, versioned Idp and SP metadata, and versioned user attributes. +Other trackers can be added by following proper interfaces. +- Trackers can run in two ways: + - synchronously - authentication data persisted during authentication event typically with multiple + queries / inserts / updates to backend storage. + - asynchronously - only authentication event job is persisted during authentication event + (one insert to backend storage). With this approach, authentication event jobs can be executed later in a separate + process using SimpleSAMLphp cron module +## Installation +Module requires SimpleSAMLphp version 2 or higher. +Module is installable using Composer: -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: - +```shell +composer require cicnavi/simplesamlphp-module-accounting ``` -cd existing_repo -git remote add origin https://gitlab.geant.org/TI_Incubator/personal-profile-page/simplesamlphp.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools -- [ ] [Set up project integrations](https://gitlab.geant.org/TI_Incubator/personal-profile-page/simplesamlphp/-/settings/integrations) +Depending on used features, module also requires: +- ext-redis: if PhpRedis is to be used as a store -## Collaborate with your team +## Configuration +As usual with SimpleSAMLphp modules, copy the module template configuration +to the SimpleSAMLphp config directory: -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. +```shell +cp modules/accounting/config-templates/module_accounting.php config/ +``` -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) +Next step is configuring available options in file config/module_accounting.php. Each option has an explanation, +however, the description of the overall concept follows. -*** +For accounting processing, the default data tracker and data provider class must be set. This tracker will be used +to persist tracking data and also to show data in the SimpleSAMLphp user interface. Here is an example excerpt +of setting the Doctrine DBAL compatible tracker class which will store authentication events, versioned Idp +and SP metadata, and versioned user attributes in a relational database: -# Editing this README +```php +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Trackers; -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. +// ... +ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER => + Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class, +// ... +``` -## Suggestions for a good README -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. +The deployer can choose if the accounting processing will be performed during authentication event (synchronously), +or in a separate process (asynchronously), for example: -## Name -Choose a self-explaining name for your project. +```php +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\ModuleConfiguration\AccountingProcessingType; -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. +// ... +ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE => + ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS, +// ... +``` -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. +If the processing type is asynchronous, then the deployer must also configure the job store related options: +- Jobs store class which will be used to store and fetch jobs from the backend store +- Accounting cron tag for job runner +- Cron module configuration (if the used tag is different from the ones available in cron module, which is the case +by default) -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. +For each tracker or job store, the "connection key" must be set. Connection key determines which connection +parameters will be forwarded for tracker / job store initialization process. -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. +Also review / edit all other configuration options, and set appropriate values. -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +### Running Setup -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +After you have configured everything in config/module_accounting.php, go to the SimpleSAMLphp Admin > Configuration +Page. There you will find a link "Accounting configuration status", which will take you on the +module configuration overview page. -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. +If the configured trackers / jobs store require any setup, you will see a "Run Setup" button, so go ahead +and click it. In the case of default Doctrine DBAL tracker / jobs store, the setup will run all migration +classes used to create necessary tables in the database. -## Contributing -State if you are open to contributions and what your requirements are for accepting them. +When the setup is finished, you'll be presented with the "Profile Page" link, which can be used by end +users to see their activity. -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. +### Adding Authentication Processing Filter +Last step to start tracking user data using the configured tracker classes / jobs store is to add an [authentication +processing filter](https://simplesamlphp.org/docs/stable/simplesamlphp-authproc.html) from the accounting module +to the right place in SimpleSAMLphp configuration. Here is an example of setting it globally for all IdPs +in config/config.php: -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. +```php +// ... +'authproc.idp' => [ + // ... + 1000 => 'accounting:Accounting', + ], +// ... +``` -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. +## TODO +- [ ] Translation -## License -For open source projects, say how it is licensed. +## Tests +To run phpcs, psalm and phpunit: -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +```shell +composer pre-commit +``` \ No newline at end of file diff --git a/bin/test.php b/bin/test.php new file mode 100644 index 0000000000000000000000000000000000000000..cf32aff522b2369015f7b4d50e3a40ccb8406181 --- /dev/null +++ b/bin/test.php @@ -0,0 +1,140 @@ +#!/usr/bin/env php +<?php +// TODO mivanci remove this file before release +declare(strict_types=1); + +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\Event\Job; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Services\Logger; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository; +use SimpleSAML\Module\accounting\Stores\Jobs\PhpRedis\RedisStore; + +require 'vendor/autoload.php'; + +$helpersManager = new HelpersManager(); + +$start = new DateTime(); + +$newLine = "\n"; + +echo "Start: " . $start->format(DateTime::ATOM); +echo $newLine; + +$job = new Job(new Event(new State(SimpleSAML\Test\Module\accounting\Constants\StateArrays::FULL))); + +$options = getopt('c:'); + +$numberOfItems = $options['c'] ?? 1000; + +echo 'Number of items: ' . $numberOfItems; +echo $newLine; + +$spinnerChars = ['|', '/', '-', '\\']; + +/**/ +echo 'Starting simulating MySQL: '; +$mysqlStartTime = new DateTime(); +echo $mysqlStartTime->format(DateTime::ATOM); +echo $newLine; + +$mysqlParameters = [ + 'driver' => 'pdo_mysql', // (string): The built-in driver implementation to use + 'user' => 'apps', // (string): Username to use when connecting to the database. + 'password' => 'apps', // (string): Password to use when connecting to the database. + 'host' => '127.0.0.1', // (string): Hostname of the database to connect to. + 'port' => 33306, // (integer): Port of the database to connect to. + 'dbname' => 'accounting', // (string): Name of the database/schema to connect to. + //'unix_socket' => 'unix_socet', // (string): Name of the socket used to connect to the database. + 'charset' => 'utf8', // (string): The charset used when connecting to the database. + //'url' => 'mysql://user:secret@localhost/mydb?charset=utf8', // ...alternative way of providing parameters. + // Additional parameters not originally avaliable in Doctrine DBAL + 'table_prefix' => '', // (string): Prefix for each table. +]; + +$logger = new Logger(); + +$jobsStoreRepository = new Repository(new Connection($mysqlParameters), 'job', $logger); +$mysqlDurationInSeconds = (new DateTime())->getTimestamp() - $mysqlStartTime->getTimestamp(); +$mysqlItemsInCurrentSecond = 0; +$mysqlItemsPerSecond = []; +for ($i = 1; $i <= $numberOfItems; $i++) { + $mysqlUpdatedDurationInSeconds = (new DateTime())->getTimestamp() - $mysqlStartTime->getTimestamp(); + if ($mysqlDurationInSeconds === $mysqlUpdatedDurationInSeconds) { + $mysqlItemsInCurrentSecond++; + } else { + $mysqlItemsPerSecond[] = $mysqlItemsInCurrentSecond; + $mysqlItemsInCurrentSecond = 0; + } + $mysqlItemsInCurrentSecond = $mysqlDurationInSeconds === $mysqlUpdatedDurationInSeconds ? + $mysqlItemsInCurrentSecond++ : 0; + $mysqlDurationInSeconds = (new DateTime())->getTimestamp() - $mysqlStartTime->getTimestamp(); + + $mysqlItemsPerSeconds = count($mysqlItemsPerSecond) ? + array_sum($mysqlItemsPerSecond) / count($mysqlItemsPerSecond) : 0; + $mysqlPercentage = $i / $numberOfItems * 100; + $spinnerChar = $spinnerChars[array_rand($spinnerChars)]; + $line = sprintf( + '%1$s percentage: %2$ 3d%%, items/s: %3$04d, duration: %4$ss', + $spinnerChar, $mysqlPercentage, $mysqlItemsPerSeconds, $mysqlDurationInSeconds + ); + echo $line; + echo "\r"; + $jobsStoreRepository->insert($job); +} +echo $newLine; +echo $newLine; + + +echo 'Starting simulating Redis: '; +$redisStartTime = new DateTime(); +echo $redisStartTime->format(DateTime::ATOM); +echo $newLine; + +$redisClient = new Redis(); +$redisClient->connect( + '127.0.0.1', + 6379, + 1, + null, + 500, + 1 +); +$redisClient->auth('apps'); +$redisClient->setOption(Redis::OPT_PREFIX, 'ssp_accounting:'); + + +$redisDurationInSeconds = (new DateTime())->getTimestamp() - $redisStartTime->getTimestamp(); +$redisItemsInCurrentSecond = 0; +$redisItemsPerSecond = []; +for ($i = 1; $i <= $numberOfItems; $i++) { + $redisUpdatedDurationInSeconds = (new DateTime())->getTimestamp() - $redisStartTime->getTimestamp(); + if ($redisDurationInSeconds === $redisUpdatedDurationInSeconds) { + $redisItemsInCurrentSecond++; + } else { + $redisItemsPerSecond[] = $redisItemsInCurrentSecond; + $redisItemsInCurrentSecond = 0; + } + $redisItemsInCurrentSecond = $redisDurationInSeconds === $redisUpdatedDurationInSeconds ? + $redisItemsInCurrentSecond++ : 0; + + $redisDurationInSeconds = $redisUpdatedDurationInSeconds; + + $redisItemsPerSeconds = count($redisItemsPerSecond) ? + array_sum($redisItemsPerSecond) / count($redisItemsPerSecond) : 0; + $redisPercentage = $i / $numberOfItems * 100; + $spinnerChar = $spinnerChars[array_rand($spinnerChars)]; + $line = sprintf( + '%1$s percentage: %2$ 3d%%, items/s: %3$04d, duration: %4$ss', + $spinnerChar, $redisPercentage, $redisItemsPerSeconds, $redisDurationInSeconds + ); + echo $line; + echo "\r"; + $redisClient->rPush(RedisStore::LIST_KEY_JOB . ':' . sha1($job->getType()), serialize($job)); +// $redisClient->rPush(RedisStore::LIST_KEY_JOB, serializgit add .e($job)); +} +echo $newLine; +echo 'End: ' . (new DateTime())->format(DateTime::ATOM); +echo $newLine; \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..c7e7ffa1bcfefd6dc6e49b725c4c2754fe264ae4 --- /dev/null +++ b/composer.json @@ -0,0 +1,54 @@ +{ + "name": "cicnavi/simplesamlphp-module-accounting", + "description": "The SimpleSAMLphp accounting module", + "type": "simplesamlphp-module", + "license": "LGPL-2.1-or-later", + "authors": [ + { + "name": "Marko Ivančić", + "email": "marko.ivancic@srce.hr" + } + ], + "config": { + "allow-plugins": { + "simplesamlphp/composer-module-installer": true + } + }, + "autoload": { + "psr-4": { + "SimpleSAML\\Module\\accounting\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SimpleSAML\\Test\\Module\\accounting\\": "tests/src/" + } + }, + "require": { + "php": "^7.4 || ^8.0", + "ext-pdo": "*", + "ext-pdo_sqlite": "*", + "doctrine/dbal": "^3", + "psr/log": "^1|^2|^3", + "simplesamlphp/composer-module-installer": "^1", + "cicnavi/simple-file-cache-php": "^2.0" + }, + "require-dev": { + "vimeo/psalm": "^4", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3", + "simplesamlphp/simplesamlphp": "^2@beta", + "simplesamlphp/simplesamlphp-test-framework": "^1" + }, + "suggest": { + "ext-pcntl": "Enables job runner to gracefully respond to SIGTERM signal.", + "ext-redis": "Mandatory if PhpRedis is to be used as a store." + }, + "scripts": { + "pre-commit": [ + "vendor/bin/phpcs -p", + "vendor/bin/psalm", + "vendor/bin/phpunit" + ] + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..40bbe7f6024d4aedca1e0dfbcd47e86d3386d135 --- /dev/null +++ b/composer.lock @@ -0,0 +1,7776 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "e1bdcb1340cc34e659c651695992a6b8", + "packages": [ + { + "name": "cicnavi/simple-file-cache-php", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/cicnavi/simple-file-cache-php.git", + "reference": "372b48b5ff364e514da80005b2367ecf1950dde4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cicnavi/simple-file-cache-php/zipball/372b48b5ff364e514da80005b2367ecf1950dde4", + "reference": "372b48b5ff364e514da80005b2367ecf1950dde4", + "shasum": "" + }, + "require": { + "ext-gmp": "*", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=7.4", + "psr/simple-cache": "^1.0" + }, + "provide": { + "psr/simple-cache-implementation": "1.0" + }, + "require-dev": { + "ext-xdebug": "*", + "phpunit/phpunit": "^9.4", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^3.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cicnavi\\SimpleFileCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marko Ivancic", + "email": "marko.ivancic@srce.hr" + } + ], + "description": "PSR-16 simple cache provider based on files.", + "support": { + "issues": "https://github.com/cicnavi/simple-file-cache-php/issues", + "source": "https://github.com/cicnavi/simple-file-cache-php/tree/v2.0.0" + }, + "time": "2021-08-18T11:28:21+00:00" + }, + { + "name": "composer/ca-bundle", + "version": "1.3.3", + "source": { + "type": "git", + "url": "https://github.com/composer/ca-bundle.git", + "reference": "30897edbfb15e784fe55587b4f73ceefd3c4d98c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/30897edbfb15e784fe55587b4f73ceefd3c4d98c", + "reference": "30897edbfb15e784fe55587b4f73ceefd3c4d98c", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-pcre": "*", + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.55", + "psr/log": "^1.0", + "symfony/phpunit-bridge": "^4.2 || ^5", + "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\CaBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", + "keywords": [ + "cabundle", + "cacert", + "certificate", + "ssl", + "tls" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/ca-bundle/issues", + "source": "https://github.com/composer/ca-bundle/tree/1.3.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-07-20T07:14:26+00:00" + }, + { + "name": "composer/class-map-generator", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "1e1cb2b791facb2dfe32932a7718cf2571187513" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/1e1cb2b791facb2dfe32932a7718cf2571187513", + "reference": "1e1cb2b791facb2dfe32932a7718cf2571187513", + "shasum": "" + }, + "require": { + "composer/pcre": "^2 || ^3", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6" + }, + "require-dev": { + "phpstan/phpstan": "^1.6", + "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/filesystem": "^5.4 || ^6", + "symfony/phpunit-bridge": "^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.0.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-06-19T11:31:27+00:00" + }, + { + "name": "composer/composer", + "version": "2.4.2", + "source": { + "type": "git", + "url": "https://github.com/composer/composer.git", + "reference": "7d887621e69a0311eb50aed4a16f7044b2b385b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/composer/zipball/7d887621e69a0311eb50aed4a16f7044b2b385b9", + "reference": "7d887621e69a0311eb50aed4a16f7044b2b385b9", + "shasum": "" + }, + "require": { + "composer/ca-bundle": "^1.0", + "composer/class-map-generator": "^1.0", + "composer/metadata-minifier": "^1.0", + "composer/pcre": "^2 || ^3", + "composer/semver": "^3.0", + "composer/spdx-licenses": "^1.5.7", + "composer/xdebug-handler": "^2.0.2 || ^3.0.3", + "justinrainbow/json-schema": "^5.2.11", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "react/promise": "^2.8", + "seld/jsonlint": "^1.4", + "seld/phar-utils": "^1.2", + "seld/signal-handler": "^2.0", + "symfony/console": "^5.4.11 || ^6.0.11", + "symfony/filesystem": "^5.4 || ^6.0", + "symfony/finder": "^5.4 || ^6.0", + "symfony/polyfill-php73": "^1.24", + "symfony/polyfill-php80": "^1.24", + "symfony/process": "^5.4 || ^6.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4.1", + "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1", + "phpstan/phpstan-symfony": "^1.2.10", + "symfony/phpunit-bridge": "^6.0" + }, + "suggest": { + "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", + "ext-zip": "Enabling the zip extension allows you to unzip archives", + "ext-zlib": "Allow gzip compression of HTTP requests" + }, + "bin": [ + "bin/composer" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.4-dev" + }, + "phpstan": { + "includes": [ + "phpstan/rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "Composer\\": "src/Composer" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "https://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", + "homepage": "https://getcomposer.org/", + "keywords": [ + "autoload", + "dependency", + "package" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/composer/issues", + "source": "https://github.com/composer/composer/tree/2.4.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-09-14T14:11:15+00:00" + }, + { + "name": "composer/metadata-minifier", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/composer/metadata-minifier.git", + "reference": "c549d23829536f0d0e984aaabbf02af91f443207" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", + "reference": "c549d23829536f0d0e984aaabbf02af91f443207", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "composer/composer": "^2", + "phpstan/phpstan": "^0.12.55", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\MetadataMinifier\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Small utility library that handles metadata minification and expansion.", + "keywords": [ + "composer", + "compression" + ], + "support": { + "issues": "https://github.com/composer/metadata-minifier/issues", + "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2021-04-07T13:37:33+00:00" + }, + { + "name": "composer/pcre", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "e300eb6c535192decd27a85bc72a9290f0d6b3bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/e300eb6c535192decd27a85bc72a9290f0d6b3bd", + "reference": "e300eb6c535192decd27a85bc72a9290f0d6b3bd", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.3", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/phpunit-bridge": "^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.0.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-02-25T20:21:48+00:00" + }, + { + "name": "composer/semver", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/3953f23262f2bff1919fc82183ad9acb13ff62c9", + "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-04-01T19:23:25+00:00" + }, + { + "name": "composer/spdx-licenses", + "version": "1.5.7", + "source": { + "type": "git", + "url": "https://github.com/composer/spdx-licenses.git", + "reference": "c848241796da2abf65837d51dce1fae55a960149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/c848241796da2abf65837d51dce1fae55a960149", + "reference": "c848241796da2abf65837d51dce1fae55a960149", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.55", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Spdx\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "SPDX licenses list and validation library.", + "keywords": [ + "license", + "spdx", + "validator" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/spdx-licenses/issues", + "source": "https://github.com/composer/spdx-licenses/tree/1.5.7" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-05-23T07:37:50+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "ced299686f41dce890debac69273b47ffe98a40c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/ced299686f41dce890debac69273b47ffe98a40c", + "reference": "ced299686f41dce890debac69273b47ffe98a40c", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/phpunit-bridge": "^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-02-25T21:32:43+00:00" + }, + { + "name": "doctrine/cache", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/cache.git", + "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", + "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", + "shasum": "" + }, + "require": { + "php": "~7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": ">2.2,<2.4" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/coding-standard": "^9", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "symfony/cache": "^4.4 || ^5.4 || ^6", + "symfony/var-exporter": "^4.4 || ^5.4 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", + "homepage": "https://www.doctrine-project.org/projects/cache.html", + "keywords": [ + "abstraction", + "apcu", + "cache", + "caching", + "couchdb", + "memcached", + "php", + "redis", + "xcache" + ], + "support": { + "issues": "https://github.com/doctrine/cache/issues", + "source": "https://github.com/doctrine/cache/tree/2.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", + "type": "tidelift" + } + ], + "time": "2022-05-20T20:07:39+00:00" + }, + { + "name": "doctrine/dbal", + "version": "3.4.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "a5a58773109c0abb13e658c8ccd92aeec8d07f9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/a5a58773109c0abb13e658c8ccd92aeec8d07f9e", + "reference": "a5a58773109c0abb13e658c8ccd92aeec8d07f9e", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/cache": "^1.11|^2.0", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1.0", + "php": "^7.4 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "10.0.0", + "jetbrains/phpstorm-stubs": "2022.2", + "phpstan/phpstan": "1.8.3", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "9.5.24", + "psalm/plugin-phpunit": "0.17.0", + "squizlabs/php_codesniffer": "3.7.1", + "symfony/cache": "^5.4|^6.0", + "symfony/console": "^4.4|^5.4|^6.0", + "vimeo/psalm": "4.27.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.4.5" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2022-09-23T17:48:57+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de", + "reference": "0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de", + "shasum": "" + }, + "require": { + "php": "^7.1|^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "phpunit/phpunit": "^7.5|^8.5|^9.5", + "psr/log": "^1|^2|^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/v1.0.0" + }, + "time": "2022-05-02T15:47:09+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "eb2ecf80e3093e8f3c2769ac838e27d8ede8e683" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/eb2ecf80e3093e8f3c2769ac838e27d8ede8e683", + "reference": "eb2ecf80e3093e8f3c2769ac838e27d8ede8e683", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "phpstan/phpstan": "~1.4.10 || ^1.5.4", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.22" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "lib/Doctrine/Common" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/1.1.2" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2022-07-27T22:18:11+00:00" + }, + { + "name": "gettext/gettext", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/php-gettext/Gettext.git", + "reference": "8657e580747bb3baacccdcebe69cac094661e404" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/8657e580747bb3baacccdcebe69cac094661e404", + "reference": "8657e580747bb3baacccdcebe69cac094661e404", + "shasum": "" + }, + "require": { + "gettext/languages": "^2.3", + "php": "^7.2|^8.0" + }, + "require-dev": { + "brick/varexporter": "^0.3.5", + "friendsofphp/php-cs-fixer": "^3.2", + "oscarotero/php-cs-fixer-config": "^2.0", + "phpunit/phpunit": "^8.0|^9.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gettext\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oscar Otero", + "email": "oom@oscarotero.com", + "homepage": "http://oscarotero.com", + "role": "Developer" + } + ], + "description": "PHP gettext manager", + "homepage": "https://github.com/php-gettext/Gettext", + "keywords": [ + "JS", + "gettext", + "i18n", + "mo", + "po", + "translation" + ], + "support": { + "email": "oom@oscarotero.com", + "issues": "https://github.com/php-gettext/Gettext/issues", + "source": "https://github.com/php-gettext/Gettext/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://paypal.me/oscarotero", + "type": "custom" + }, + { + "url": "https://github.com/oscarotero", + "type": "github" + }, + { + "url": "https://www.patreon.com/misteroom", + "type": "patreon" + } + ], + "time": "2022-07-27T19:54:55+00:00" + }, + { + "name": "gettext/languages", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/php-gettext/Languages.git", + "reference": "ed56dd2c7f4024cc953ed180d25f02f2640e3ffa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-gettext/Languages/zipball/ed56dd2c7f4024cc953ed180d25f02f2640e3ffa", + "reference": "ed56dd2c7f4024cc953ed180d25f02f2640e3ffa", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4" + }, + "bin": [ + "bin/export-plural-rules" + ], + "type": "library", + "autoload": { + "psr-4": { + "Gettext\\Languages\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michele Locati", + "email": "mlocati@gmail.com", + "role": "Developer" + } + ], + "description": "gettext languages with plural rules", + "homepage": "https://github.com/php-gettext/Languages", + "keywords": [ + "cldr", + "i18n", + "internationalization", + "l10n", + "language", + "languages", + "localization", + "php", + "plural", + "plural rules", + "plurals", + "translate", + "translations", + "unicode" + ], + "support": { + "issues": "https://github.com/php-gettext/Languages/issues", + "source": "https://github.com/php-gettext/Languages/tree/2.9.0" + }, + "funding": [ + { + "url": "https://paypal.me/mlocati", + "type": "custom" + }, + { + "url": "https://github.com/mlocati", + "type": "github" + } + ], + "time": "2021-11-11T17:30:39+00:00" + }, + { + "name": "gettext/translator", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/php-gettext/Translator.git", + "reference": "b18ff33e8203de623854561f5e47e992fc5c50bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-gettext/Translator/zipball/b18ff33e8203de623854561f5e47e992fc5c50bb", + "reference": "b18ff33e8203de623854561f5e47e992fc5c50bb", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.15", + "gettext/gettext": "^5.0.0", + "oscarotero/php-cs-fixer-config": "^1.0", + "phpunit/phpunit": "^8.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "suggest": { + "gettext/gettext": "Is necessary to load and generate array files used by the translator" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gettext\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oscar Otero", + "email": "oom@oscarotero.com", + "homepage": "http://oscarotero.com", + "role": "Developer" + } + ], + "description": "Gettext translator functions", + "homepage": "https://github.com/php-gettext/Translator", + "keywords": [ + "gettext", + "i18n", + "php", + "translator" + ], + "support": { + "email": "oom@oscarotero.com", + "issues": "https://github.com/php-gettext/Translator/issues", + "source": "https://github.com/php-gettext/Translator/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://paypal.me/oscarotero", + "type": "custom" + }, + { + "url": "https://github.com/oscarotero", + "type": "github" + }, + { + "url": "https://www.patreon.com/misteroom", + "type": "patreon" + } + ], + "time": "2022-02-23T20:29:40+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "5.2.12", + "source": { + "type": "git", + "url": "https://github.com/justinrainbow/json-schema.git", + "reference": "ad87d5a5ca981228e0e205c2bc7dfb8e24559b60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/ad87d5a5ca981228e0e205c2bc7dfb8e24559b60", + "reference": "ad87d5a5ca981228e0e205c2bc7dfb8e24559b60", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "json-schema/json-schema-test-suite": "1.2.0", + "phpunit/phpunit": "^4.8.35" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/justinrainbow/json-schema/issues", + "source": "https://github.com/justinrainbow/json-schema/tree/5.2.12" + }, + "time": "2022-04-13T08:02:27+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.6.4", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "a94fdebaea6bd17f51be0c2373ab80d3d681269b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/a94fdebaea6bd17f51be0c2373ab80d3d681269b", + "reference": "a94fdebaea6bd17f51be0c2373ab80d3d681269b", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", + "doctrine/annotations": "^1.2", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.6.2", + "yoast/phpunit-polyfills": "^1.0.0" + }, + "suggest": { + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "stevenmaguire/oauth2-microsoft": "Needed for Microsoft XOAUTH2 authentication", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.6.4" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2022-08-22T09:22:00+00:00" + }, + { + "name": "psr/cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/master" + }, + "time": "2016-08-06T20:24:11+00:00" + }, + { + "name": "psr/container", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/1.1.2" + }, + "time": "2021-11-05T16:50:12+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "psr/simple-cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/master" + }, + "time": "2017-10-23T01:57:42+00:00" + }, + { + "name": "react/promise", + "version": "v2.9.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "234f8fd1023c9158e2314fa9d7d0e6a83db42910" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/234f8fd1023c9158e2314fa9d7d0e6a83db42910", + "reference": "234f8fd1023c9158e2314fa9d7d0e6a83db42910", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v2.9.0" + }, + "funding": [ + { + "url": "https://github.com/WyriHaximus", + "type": "github" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-02-11T10:27:51+00:00" + }, + { + "name": "robrichards/xmlseclibs", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/f8f19e58f26cdb42c54b214ff8a820760292f8df", + "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">= 5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobRichards\\XMLSecLibs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP library for XML Security", + "homepage": "https://github.com/robrichards/xmlseclibs", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "support": { + "issues": "https://github.com/robrichards/xmlseclibs/issues", + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.1" + }, + "time": "2020-09-05T13:00:25+00:00" + }, + { + "name": "seld/jsonlint", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "4211420d25eba80712bff236a98960ef68b866b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/4211420d25eba80712bff236a98960ef68b866b7", + "reference": "4211420d25eba80712bff236a98960ef68b866b7", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.5", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" + }, + "bin": [ + "bin/jsonlint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Seld\\JsonLint\\": "src/Seld/JsonLint/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], + "support": { + "issues": "https://github.com/Seldaek/jsonlint/issues", + "source": "https://github.com/Seldaek/jsonlint/tree/1.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" + } + ], + "time": "2022-04-01T13:37:23+00:00" + }, + { + "name": "seld/phar-utils", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/phar-utils.git", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\PharUtils\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + } + ], + "description": "PHAR file format utilities, for when PHP phars you up", + "keywords": [ + "phar" + ], + "support": { + "issues": "https://github.com/Seldaek/phar-utils/issues", + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + }, + "time": "2022-08-31T10:31:18+00:00" + }, + { + "name": "seld/signal-handler", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/signal-handler.git", + "reference": "f69d119511dc0360440cdbdaa71829c149b7be75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/signal-handler/zipball/f69d119511dc0360440cdbdaa71829c149b7be75", + "reference": "f69d119511dc0360440cdbdaa71829c149b7be75", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^7.5.20 || ^8.5.23", + "psr/log": "^1 || ^2 || ^3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\Signal\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Simple unix signal handler that silently fails where signals are not supported for easy cross-platform development", + "keywords": [ + "posix", + "sigint", + "signal", + "sigterm", + "unix" + ], + "support": { + "issues": "https://github.com/Seldaek/signal-handler/issues", + "source": "https://github.com/Seldaek/signal-handler/tree/2.0.1" + }, + "time": "2022-07-20T18:31:45+00:00" + }, + { + "name": "simplesamlphp/assert", + "version": "v0.8.0", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/assert.git", + "reference": "d3b0f38f4ae083822471c15e3c4a0401ddaeac73" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/assert/zipball/d3b0f38f4ae083822471c15e3c4a0401ddaeac73", + "reference": "d3b0f38f4ae083822471c15e3c4a0401ddaeac73", + "shasum": "" + }, + "require": { + "ext-spl": "*", + "php": "^7.4 || ^8.0", + "webmozart/assert": "^1.11" + }, + "require-dev": { + "simplesamlphp/simplesamlphp-test-framework": "^1.2.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "v0.8.x-dev" + } + }, + "autoload": { + "psr-4": { + "SimpleSAML\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Tim van Dijen", + "email": "tvdijen@gmail.com" + }, + { + "name": "Jaime Perez Crespo", + "email": "jaimepc@gmail.com" + } + ], + "description": "A wrapper around webmozart/assert to make it useful beyond checking method arguments", + "support": { + "issues": "https://github.com/simplesamlphp/assert/issues", + "source": "https://github.com/simplesamlphp/assert/tree/v0.8.0" + }, + "time": "2022-09-20T20:18:55+00:00" + }, + { + "name": "simplesamlphp/composer-module-installer", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/composer-module-installer.git", + "reference": "27b4fe96198ffaff3ab49c87b40f4cb24de77b01" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/composer-module-installer/zipball/27b4fe96198ffaff3ab49c87b40f4cb24de77b01", + "reference": "27b4fe96198ffaff3ab49c87b40f4cb24de77b01", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1 || ^2.0", + "php": "^7.4 || ^8.0", + "simplesamlphp/simplesamlphp": "*" + }, + "type": "composer-plugin", + "extra": { + "class": "SimpleSAML\\Composer\\ModuleInstallerPlugin" + }, + "autoload": { + "psr-4": { + "SimpleSAML\\Composer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "description": "A Composer plugin that allows installing SimpleSAMLphp modules through Composer.", + "support": { + "issues": "https://github.com/simplesamlphp/composer-module-installer/issues", + "source": "https://github.com/simplesamlphp/composer-module-installer/tree/v1.2.0" + }, + "time": "2022-08-31T17:20:27+00:00" + }, + { + "name": "simplesamlphp/saml2", + "version": "v4.6.3", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/saml2.git", + "reference": "bfc9c79dd6b728a41d1de988f545f6e64728a51d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/saml2/zipball/bfc9c79dd6b728a41d1de988f545f6e64728a51d", + "reference": "bfc9c79dd6b728a41d1de988f545f6e64728a51d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-openssl": "*", + "ext-zlib": "*", + "php": ">=7.1 || ^8.0", + "psr/log": "~1.1 || ^2.0 || ^3.0", + "robrichards/xmlseclibs": "^3.1.1", + "webmozart/assert": "^1.9" + }, + "require-dev": { + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "sebastian/phpcpd": "~4.1 || ^5.0 || ^6.0", + "simplesamlphp/simplesamlphp-test-framework": "~0.1.0", + "squizlabs/php_codesniffer": "~3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "v4.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "SAML2\\": "src/SAML2" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Andreas Åkre Solberg", + "email": "andreas.solberg@uninett.no" + } + ], + "description": "SAML2 PHP library from SimpleSAMLphp", + "support": { + "issues": "https://github.com/simplesamlphp/saml2/issues", + "source": "https://github.com/simplesamlphp/saml2/tree/v4.6.3" + }, + "time": "2022-06-13T14:04:10+00:00" + }, + { + "name": "simplesamlphp/simplesamlphp", + "version": "v2.0.0-rc2", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/simplesamlphp.git", + "reference": "2cf4ec863ab9aa59eb4ad0b5287ab8ab97360089" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp/zipball/2cf4ec863ab9aa59eb4ad0b5287ab8ab97360089", + "reference": "2cf4ec863ab9aa59eb4ad0b5287ab8ab97360089", + "shasum": "" + }, + "require": { + "composer/composer": "^2.3", + "ext-date": "*", + "ext-dom": "*", + "ext-hash": "*", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-pcre": "*", + "ext-spl": "*", + "ext-zlib": "*", + "gettext/gettext": "^5.6.1", + "gettext/translator": "^1.0.1", + "php": ">=7.4 || ^8.0", + "phpmailer/phpmailer": "^6.5", + "simplesamlphp/assert": "^0.8.0", + "simplesamlphp/saml2": "^4.6", + "symfony/cache": "^5.4", + "symfony/config": "^5.4", + "symfony/console": "^5.4", + "symfony/dependency-injection": "^5.4", + "symfony/filesystem": "^5.4", + "symfony/finder": "^5.4", + "symfony/framework-bundle": "^5.4", + "symfony/http-foundation": "^5.4", + "symfony/http-kernel": "^5.4", + "symfony/intl": "^5.4", + "symfony/routing": "^5.4", + "symfony/translation-contracts": "^2.5", + "symfony/twig-bridge": "^5.4", + "symfony/var-exporter": "^5.4", + "symfony/yaml": "^5.4", + "twig/intl-extra": "^3.3", + "twig/twig": "^3.3.8" + }, + "require-dev": { + "ext-curl": "*", + "ext-pdo_sqlite": "*", + "mikey179/vfsstream": "~1.6", + "simplesamlphp/simplesamlphp-module-adfs": ">=2.0.0-rc5", + "simplesamlphp/simplesamlphp-test-framework": "^1.2.1", + "simplesamlphp/xml-security": "^0.6.6" + }, + "suggest": { + "ext-curl": "Needed in order to check for updates automatically", + "ext-ldap": "Needed if an LDAP backend is used", + "ext-memcache": "Needed if a Memcache server is used to store session information", + "ext-mysql": "Needed if a MySQL backend is used, either for authentication or to store session information", + "ext-pdo": "Needed if a database backend is used, either for authentication or to store session information", + "ext-pgsql": "Needed if a PostgreSQL backend is used, either for authentication or to store session information", + "predis/predis": "Needed if a Redis server is used to store session information" + }, + "type": "project", + "autoload": { + "files": [ + "src/_autoload_modules.php" + ], + "psr-4": { + "SimpleSAML\\": "src/SimpleSAML", + "SimpleSAML\\Module\\core\\": "modules/core/src", + "SimpleSAML\\Module\\cron\\": "modules/cron/src", + "SimpleSAML\\Module\\saml\\": "modules/saml/src", + "SimpleSAML\\Module\\admin\\": "modules/admin/src", + "SimpleSAML\\Module\\multiauth\\": "modules/multiauth/src", + "SimpleSAML\\Module\\exampleauth\\": "modules/exampleauth/src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Andreas Åkre Solberg", + "email": "andreas.solberg@uninett.no" + }, + { + "name": "Olav Morken", + "email": "olav.morken@uninett.no" + }, + { + "name": "Jaime Perez", + "email": "jaime.perez@uninett.no" + } + ], + "description": "A PHP implementation of a SAML 2.0 service provider and identity provider.", + "homepage": "http://simplesamlphp.org", + "keywords": [ + "SAML2", + "idp", + "oauth", + "shibboleth", + "sp", + "ws-federation" + ], + "support": { + "issues": "https://github.com/simplesamlphp/simplesamlphp/issues", + "source": "https://github.com/simplesamlphp/simplesamlphp" + }, + "time": "2022-09-22T06:38:05+00:00" + }, + { + "name": "symfony/cache", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "5a0fff46df349f0db3fe242263451fddf5277362" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/5a0fff46df349f0db3fe242263451fddf5277362", + "reference": "5a0fff46df349f0db3fe242263451fddf5277362", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/cache": "^1.0|^2.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^1.1.7|^2", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php73": "^1.9", + "symfony/polyfill-php80": "^1.16", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/var-exporter": "^4.4|^5.0|^6.0" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/dependency-injection": "<4.4", + "symfony/http-kernel": "<4.4", + "symfony/var-dumper": "<4.4" + }, + "provide": { + "psr/cache-implementation": "1.0|2.0", + "psr/simple-cache-implementation": "1.0|2.0", + "symfony/cache-implementation": "1.0|2.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/cache": "^1.6|^2.0", + "doctrine/dbal": "^2.13.1|^3.0", + "predis/predis": "^1.1", + "psr/simple-cache": "^1.0|^2.0", + "symfony/config": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/filesystem": "^4.4|^5.0|^6.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0", + "symfony/messenger": "^4.4|^5.0|^6.0", + "symfony/var-dumper": "^4.4|^5.0|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an extended PSR-6, PSR-16 (and tags) implementation", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-28T15:25:17+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "64be4a7acb83b6f2bf6de9a02cee6dad41277ebc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/64be4a7acb83b6f2bf6de9a02cee6dad41277ebc", + "reference": "64be4a7acb83b6f2bf6de9a02cee6dad41277ebc", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/cache": "^1.0|^2.0|^3.0" + }, + "suggest": { + "symfony/cache-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:53:40+00:00" + }, + { + "name": "symfony/config", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "ec79e03125c1d2477e43dde8528535d90cc78379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/ec79e03125c1d2477e43dde8528535d90cc78379", + "reference": "ec79e03125c1d2477e43dde8528535d90cc78379", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/filesystem": "^4.4|^5.0|^6.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php80": "^1.16", + "symfony/polyfill-php81": "^1.22" + }, + "conflict": { + "symfony/finder": "<4.4" + }, + "require-dev": { + "symfony/event-dispatcher": "^4.4|^5.0|^6.0", + "symfony/finder": "^4.4|^5.0|^6.0", + "symfony/messenger": "^4.4|^5.0|^6.0", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/yaml": "^4.4|^5.0|^6.0" + }, + "suggest": { + "symfony/yaml": "To use the yaml reference dumper" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-20T13:00:38+00:00" + }, + { + "name": "symfony/console", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "c072aa8f724c3af64e2c7a96b796a4863d24dba1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/c072aa8f724c3af64e2c7a96b796a4863d24dba1", + "reference": "c072aa8f724c3af64e2c7a96b796a4863d24dba1", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.9", + "symfony/polyfill-php80": "^1.16", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/string": "^5.1|^6.0" + }, + "conflict": { + "psr/log": ">=3", + "symfony/dependency-injection": "<4.4", + "symfony/dotenv": "<5.1", + "symfony/event-dispatcher": "<4.4", + "symfony/lock": "<4.4", + "symfony/process": "<4.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0" + }, + "require-dev": { + "psr/log": "^1|^2", + "symfony/config": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/event-dispatcher": "^4.4|^5.0|^6.0", + "symfony/lock": "^4.4|^5.0|^6.0", + "symfony/process": "^4.4|^5.0|^6.0", + "symfony/var-dumper": "^4.4|^5.0|^6.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v5.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-08-17T13:18:05+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "a8b9251016e9476db73e25fa836904bc0bf74c62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a8b9251016e9476db73e25fa836904bc0bf74c62", + "reference": "a8b9251016e9476db73e25fa836904bc0bf74c62", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.1.1", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16", + "symfony/polyfill-php81": "^1.22", + "symfony/service-contracts": "^1.1.6|^2" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<5.3", + "symfony/finder": "<4.4", + "symfony/proxy-manager-bridge": "<4.4", + "symfony/yaml": "<4.4.26" + }, + "provide": { + "psr/container-implementation": "1.0", + "symfony/service-implementation": "1.0|2.0" + }, + "require-dev": { + "symfony/config": "^5.3|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/yaml": "^4.4.26|^5.0|^6.0" + }, + "suggest": { + "symfony/config": "", + "symfony/expression-language": "For using expressions in service container configuration", + "symfony/finder": "For using double-star glob patterns or when GLOB_BRACE portability is required", + "symfony/proxy-manager-bridge": "Generate service proxies to lazy load them", + "symfony/yaml": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-20T13:00:38+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:53:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "f75d17cb4769eb38cd5fccbda95cd80a054d35c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/f75d17cb4769eb38cd5fccbda95cd80a054d35c8", + "reference": "f75d17cb4769eb38cd5fccbda95cd80a054d35c8", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^4.4|^5.0|^6.0" + }, + "require-dev": { + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/http-kernel": "^4.4|^5.0|^6.0", + "symfony/serializer": "^4.4|^5.0|^6.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-29T07:37:50+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v5.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "8e6ce1cc0279e3ff3c8ff0f43813bc88d21ca1bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/8e6ce1cc0279e3ff3c8ff0f43813bc88d21ca1bc", + "reference": "8e6ce1cc0279e3ff3c8ff0f43813bc88d21ca1bc", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/event-dispatcher-contracts": "^2|^3", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "symfony/dependency-injection": "<4.4" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/error-handler": "^4.4|^5.0|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/http-foundation": "^4.4|^5.0|^6.0", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/stopwatch": "^4.4|^5.0|^6.0" + }, + "suggest": { + "symfony/dependency-injection": "", + "symfony/http-kernel": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-05T16:45:39+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "f98b54df6ad059855739db6fcbc2d36995283fe1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/f98b54df6ad059855739db6fcbc2d36995283fe1", + "reference": "f98b54df6ad059855739db6fcbc2d36995283fe1", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/event-dispatcher": "^1" + }, + "suggest": { + "symfony/event-dispatcher-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:53:40+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "2d67c1f9a1937406a9be3171b4b22250c0a11447" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/2d67c1f9a1937406a9be3171b4b22250c0a11447", + "reference": "2d67c1f9a1937406a9be3171b4b22250c0a11447", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v5.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-08-02T13:48:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "7872a66f57caffa2916a584db1aa7f12adc76f8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/7872a66f57caffa2916a584db1aa7f12adc76f8c", + "reference": "7872a66f57caffa2916a584db1aa7f12adc76f8c", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-29T07:37:50+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "49f8fe5d39b7513a3f26898788885dbe66b0d910" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/49f8fe5d39b7513a3f26898788885dbe66b0d910", + "reference": "49f8fe5d39b7513a3f26898788885dbe66b0d910", + "shasum": "" + }, + "require": { + "ext-xml": "*", + "php": ">=7.2.5", + "symfony/cache": "^5.2|^6.0", + "symfony/config": "^5.3|^6.0", + "symfony/dependency-injection": "^5.4.5|^6.0.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/error-handler": "^4.4.1|^5.0.1|^6.0", + "symfony/event-dispatcher": "^5.1|^6.0", + "symfony/filesystem": "^4.4|^5.0|^6.0", + "symfony/finder": "^4.4|^5.0|^6.0", + "symfony/http-foundation": "^5.3|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.16", + "symfony/polyfill-php81": "^1.22", + "symfony/routing": "^5.3|^6.0" + }, + "conflict": { + "doctrine/annotations": "<1.13.1", + "doctrine/cache": "<1.11", + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "phpunit/phpunit": "<5.4.3", + "symfony/asset": "<5.3", + "symfony/console": "<5.2.5", + "symfony/dom-crawler": "<4.4", + "symfony/dotenv": "<5.1", + "symfony/form": "<5.2", + "symfony/http-client": "<4.4", + "symfony/lock": "<4.4", + "symfony/mailer": "<5.2", + "symfony/messenger": "<5.4", + "symfony/mime": "<4.4", + "symfony/property-access": "<5.3", + "symfony/property-info": "<4.4", + "symfony/security-csrf": "<5.3", + "symfony/serializer": "<5.2", + "symfony/service-contracts": ">=3.0", + "symfony/stopwatch": "<4.4", + "symfony/translation": "<5.3", + "symfony/twig-bridge": "<4.4", + "symfony/twig-bundle": "<4.4", + "symfony/validator": "<5.2", + "symfony/web-profiler-bundle": "<4.4", + "symfony/workflow": "<5.2" + }, + "require-dev": { + "doctrine/annotations": "^1.13.1", + "doctrine/cache": "^1.11|^2.0", + "doctrine/persistence": "^1.3|^2|^3", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/asset": "^5.3|^6.0", + "symfony/browser-kit": "^5.4|^6.0", + "symfony/console": "^5.4.9|^6.0.9", + "symfony/css-selector": "^4.4|^5.0|^6.0", + "symfony/dom-crawler": "^4.4.30|^5.3.7|^6.0", + "symfony/dotenv": "^5.1|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/form": "^5.2|^6.0", + "symfony/http-client": "^4.4|^5.0|^6.0", + "symfony/lock": "^4.4|^5.0|^6.0", + "symfony/mailer": "^5.2|^6.0", + "symfony/messenger": "^5.4|^6.0", + "symfony/mime": "^4.4|^5.0|^6.0", + "symfony/notifier": "^5.4|^6.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^4.4|^5.0|^6.0", + "symfony/property-info": "^4.4|^5.0|^6.0", + "symfony/rate-limiter": "^5.2|^6.0", + "symfony/security-bundle": "^5.4|^6.0", + "symfony/serializer": "^5.4|^6.0", + "symfony/stopwatch": "^4.4|^5.0|^6.0", + "symfony/string": "^5.0|^6.0", + "symfony/translation": "^5.3|^6.0", + "symfony/twig-bundle": "^4.4|^5.0|^6.0", + "symfony/validator": "^5.2|^6.0", + "symfony/web-link": "^4.4|^5.0|^6.0", + "symfony/workflow": "^5.2|^6.0", + "symfony/yaml": "^4.4|^5.0|^6.0", + "twig/twig": "^2.10|^3.0" + }, + "suggest": { + "ext-apcu": "For best performance of the system caches", + "symfony/console": "For using the console commands", + "symfony/form": "For using forms", + "symfony/property-info": "For using the property_info service", + "symfony/serializer": "For using the serializer service", + "symfony/validator": "For using validation", + "symfony/web-link": "For using web links, features such as preloading, prefetching or prerendering", + "symfony/yaml": "For using the debug:config and lint:yaml commands" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v5.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-08-26T10:32:10+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "f4bfe9611b113b15d98a43da68ec9b5a00d56791" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f4bfe9611b113b15d98a43da68ec9b5a00d56791", + "reference": "f4bfe9611b113b15d98a43da68ec9b5a00d56791", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "predis/predis": "~1.0", + "symfony/cache": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4", + "symfony/mime": "^4.4|^5.0|^6.0", + "symfony/rate-limiter": "^5.2|^6.0" + }, + "suggest": { + "symfony/mime": "To use the file extension guesser" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v5.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-08-19T07:33:17+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "37f660fa3bcd78fe4893ce23ebe934618ec099be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/37f660fa3bcd78fe4893ce23ebe934618ec099be", + "reference": "37f660fa3bcd78fe4893ce23ebe934618ec099be", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/log": "^1|^2", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/error-handler": "^4.4|^5.0|^6.0", + "symfony/event-dispatcher": "^5.0|^6.0", + "symfony/http-foundation": "^5.3.7|^6.0", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-php73": "^1.9", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "symfony/browser-kit": "<5.4", + "symfony/cache": "<5.0", + "symfony/config": "<5.0", + "symfony/console": "<4.4", + "symfony/dependency-injection": "<5.3", + "symfony/doctrine-bridge": "<5.0", + "symfony/form": "<5.0", + "symfony/http-client": "<5.0", + "symfony/mailer": "<5.0", + "symfony/messenger": "<5.0", + "symfony/translation": "<5.0", + "symfony/twig-bridge": "<5.0", + "symfony/validator": "<5.0", + "twig/twig": "<2.13" + }, + "provide": { + "psr/log-implementation": "1.0|2.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^5.4|^6.0", + "symfony/config": "^5.0|^6.0", + "symfony/console": "^4.4|^5.0|^6.0", + "symfony/css-selector": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^5.3|^6.0", + "symfony/dom-crawler": "^4.4|^5.0|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/finder": "^4.4|^5.0|^6.0", + "symfony/http-client-contracts": "^1.1|^2|^3", + "symfony/process": "^4.4|^5.0|^6.0", + "symfony/routing": "^4.4|^5.0|^6.0", + "symfony/stopwatch": "^4.4|^5.0|^6.0", + "symfony/translation": "^4.4|^5.0|^6.0", + "symfony/translation-contracts": "^1.1|^2|^3", + "twig/twig": "^2.13|^3.0.4" + }, + "suggest": { + "symfony/browser-kit": "", + "symfony/config": "", + "symfony/console": "", + "symfony/dependency-injection": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v5.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-08-26T14:40:40+00:00" + }, + { + "name": "symfony/intl", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "d305c0c1d31b30b3876e041804c35e49e5f8a96e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/d305c0c1d31b30b3876e041804c35e49e5f8a96e", + "reference": "d305c0c1d31b30b3876e041804c35e49e5f8a96e", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "symfony/filesystem": "^4.4|^5.0|^6.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a PHP replacement layer for the C intl extension that includes additional data from the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-20T11:34:24+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "433d05519ce6990bf3530fba6957499d327395c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/433d05519ce6990bf3530fba6957499d327395c2", + "reference": "433d05519ce6990bf3530fba6957499d327395c2", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "219aa369ceff116e673852dce47c3a41794c14bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/219aa369ceff116e673852dce47c3a41794c14bd", + "reference": "219aa369ceff116e673852dce47c3a41794c14bd", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/e440d35fa0286f77fb45b79a03fedbeda9307e85", + "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-10T07:21:04+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "13f6d1271c663dc5ae9fb843a8f16521db7687a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/13f6d1271c663dc5ae9fb843a8f16521db7687a1", + "reference": "13f6d1271c663dc5ae9fb843a8f16521db7687a1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/process", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "6e75fe6874cbc7e4773d049616ab450eff537bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/6e75fe6874cbc7e4773d049616ab450eff537bf1", + "reference": "6e75fe6874cbc7e4773d049616ab450eff537bf1", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-06-27T16:58:25+00:00" + }, + { + "name": "symfony/routing", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3e01ccd9b2a3a4167ba2b3c53612762300300226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3e01ccd9b2a3a4167ba2b3c53612762300300226", + "reference": "3e01ccd9b2a3a4167ba2b3c53612762300300226", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "symfony/config": "<5.3", + "symfony/dependency-injection": "<4.4", + "symfony/yaml": "<4.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12", + "psr/log": "^1|^2|^3", + "symfony/config": "^5.3|^6.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/http-foundation": "^4.4|^5.0|^6.0", + "symfony/yaml": "^4.4|^5.0|^6.0" + }, + "suggest": { + "symfony/config": "For using the all-in-one router or any loader", + "symfony/expression-language": "For using expression matching", + "symfony/http-foundation": "For using a Symfony Request object", + "symfony/yaml": "For using the YAML loader" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-20T13:00:38+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c", + "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.1", + "symfony/deprecation-contracts": "^2.1|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-30T19:17:29+00:00" + }, + { + "name": "symfony/string", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "2fc515e512d721bf31ea76bd02fe23ada4640058" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/2fc515e512d721bf31ea76bd02fe23ada4640058", + "reference": "2fc515e512d721bf31ea76bd02fe23ada4640058", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "~1.15" + }, + "conflict": { + "symfony/translation-contracts": ">=3.0" + }, + "require-dev": { + "symfony/error-handler": "^4.4|^5.0|^6.0", + "symfony/http-client": "^4.4|^5.0|^6.0", + "symfony/translation-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.4|^5.0|^6.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v5.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-08-12T17:03:11+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "136b19dd05cdf0709db6537d058bcab6dd6e2dbe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/136b19dd05cdf0709db6537d058bcab6dd6e2dbe", + "reference": "136b19dd05cdf0709db6537d058bcab6dd6e2dbe", + "shasum": "" + }, + "require": { + "php": ">=7.2.5" + }, + "suggest": { + "symfony/translation-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-06-27T16:58:25+00:00" + }, + { + "name": "symfony/twig-bridge", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "94c3b38514c953e3e84719c96d4e578a01ca1819" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/94c3b38514c953e3e84719c96d4e578a01ca1819", + "reference": "94c3b38514c953e3e84719c96d4e578a01ca1819", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16", + "symfony/translation-contracts": "^1.1|^2|^3", + "twig/twig": "^2.13|^3.0.4" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/console": "<5.3", + "symfony/form": "<5.3", + "symfony/http-foundation": "<5.3", + "symfony/http-kernel": "<4.4", + "symfony/translation": "<5.2", + "symfony/workflow": "<5.2" + }, + "require-dev": { + "doctrine/annotations": "^1.12", + "egulias/email-validator": "^2.1.10|^3", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/asset": "^4.4|^5.0|^6.0", + "symfony/console": "^5.3|^6.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/finder": "^4.4|^5.0|^6.0", + "symfony/form": "^5.3|^6.0", + "symfony/http-foundation": "^5.3|^6.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0", + "symfony/intl": "^4.4|^5.0|^6.0", + "symfony/mime": "^5.2|^6.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^4.4|^5.1|^6.0", + "symfony/routing": "^4.4|^5.0|^6.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^4.4|^5.0|^6.0", + "symfony/security-csrf": "^4.4|^5.0|^6.0", + "symfony/security-http": "^4.4|^5.0|^6.0", + "symfony/serializer": "^5.2|^6.0", + "symfony/stopwatch": "^4.4|^5.0|^6.0", + "symfony/translation": "^5.2|^6.0", + "symfony/web-link": "^4.4|^5.0|^6.0", + "symfony/workflow": "^5.2|^6.0", + "symfony/yaml": "^4.4|^5.0|^6.0", + "twig/cssinliner-extra": "^2.12|^3", + "twig/inky-extra": "^2.12|^3", + "twig/markdown-extra": "^2.12|^3" + }, + "suggest": { + "symfony/asset": "For using the AssetExtension", + "symfony/expression-language": "For using the ExpressionExtension", + "symfony/finder": "", + "symfony/form": "For using the FormExtension", + "symfony/http-kernel": "For using the HttpKernelExtension", + "symfony/routing": "For using the RoutingExtension", + "symfony/security-core": "For using the SecurityExtension", + "symfony/security-csrf": "For using the CsrfExtension", + "symfony/security-http": "For using the LogoutUrlExtension", + "symfony/stopwatch": "For using the StopwatchExtension", + "symfony/translation": "For using the TranslationExtension", + "symfony/var-dumper": "For using the DumpExtension", + "symfony/web-link": "For using the WebLinkExtension", + "symfony/yaml": "For using the YamlExtension" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v5.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-08-03T13:09:21+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v5.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "b8f306d7b8ef34fb3db3305be97ba8e088fb4861" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8f306d7b8ef34fb3db3305be97ba8e088fb4861", + "reference": "b8f306d7b8ef34fb3db3305be97ba8e088fb4861", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "phpunit/phpunit": "<5.4.3", + "symfony/console": "<4.4" + }, + "require-dev": { + "ext-iconv": "*", + "symfony/console": "^4.4|^5.0|^6.0", + "symfony/process": "^4.4|^5.0|^6.0", + "symfony/uid": "^5.1|^6.0", + "twig/twig": "^2.13|^3.0.4" + }, + "suggest": { + "ext-iconv": "To convert non-UTF-8 strings to UTF-8 (or symfony/polyfill-iconv in case ext-iconv cannot be used).", + "ext-intl": "To show region name in time zone dump", + "symfony/console": "To use the ServerDumpCommand and/or the bin/var-dump-server script" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v5.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-20T13:00:38+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v5.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "8fc03ee75eeece3d9be1ef47d26d79bea1afb340" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/8fc03ee75eeece3d9be1ef47d26d79bea1afb340", + "reference": "8fc03ee75eeece3d9be1ef47d26d79bea1afb340", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "symfony/var-dumper": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v5.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-27T12:56:18+00:00" + }, + { + "name": "symfony/yaml", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "7a3aa21ac8ab1a96cc6de5bbcab4bc9fc943b18c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/7a3aa21ac8ab1a96cc6de5bbcab4bc9fc943b18c", + "reference": "7a3aa21ac8ab1a96cc6de5bbcab4bc9fc943b18c", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<5.3" + }, + "require-dev": { + "symfony/console": "^5.3|^6.0" + }, + "suggest": { + "symfony/console": "For validating YAML files using the lint command" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v5.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-08-02T15:52:22+00:00" + }, + { + "name": "twig/intl-extra", + "version": "v3.4.2", + "source": { + "type": "git", + "url": "https://github.com/twigphp/intl-extra.git", + "reference": "151e50fad9c7915bd56f0adf3f0cb3c47e6ed28a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/151e50fad9c7915bd56f0adf3f0cb3c47e6ed28a", + "reference": "151e50fad9c7915bd56f0adf3f0cb3c47e6ed28a", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/intl": "^4.4|^5.0|^6.0", + "twig/twig": "^2.7|^3.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "psr-4": { + "Twig\\Extra\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Intl", + "homepage": "https://twig.symfony.com", + "keywords": [ + "intl", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/intl-extra/tree/v3.4.2" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2022-06-10T08:33:05+00:00" + }, + { + "name": "twig/twig", + "version": "v3.4.3", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "c38fd6b0b7f370c198db91ffd02e23b517426b58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/c38fd6b0b7f370c198db91ffd02e23b517426b58", + "reference": "c38fd6b0b7f370c198db91ffd02e23b517426b58", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "psr/container": "^1.0", + "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4-dev" + } + }, + "autoload": { + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.4.3" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2022-09-28T08:42:51+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" + } + ], + "packages-dev": [ + { + "name": "amphp/amp", + "version": "v2.6.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "9d5100cebffa729aaffecd3ad25dc5aeea4f13bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/9d5100cebffa729aaffecd3ad25dc5aeea4f13bb", + "reference": "9d5100cebffa729aaffecd3ad25dc5aeea4f13bb", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1", + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^7 | ^8 | ^9", + "psalm/phar": "^3.11@dev", + "react/promise": "^2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "files": [ + "lib/functions.php", + "lib/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "irc": "irc://irc.freenode.org/amphp", + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v2.6.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2022-02-20T17:52:18+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v1.8.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "acbd8002b3536485c997c4e019206b3f10ca15bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/acbd8002b3536485c997c4e019206b3f10ca15bd", + "reference": "acbd8002b3536485c997c4e019206b3f10ca15bd", + "shasum": "" + }, + "require": { + "amphp/amp": "^2", + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "amphp/phpunit-util": "^1.4", + "friendsofphp/php-cs-fixer": "^2.3", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^6 || ^7 || ^8", + "psalm/phar": "^3.11.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "http://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "irc": "irc://irc.freenode.org/amphp", + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v1.8.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2021-03-30T17:13:30+00:00" + }, + { + "name": "composer/package-versions-deprecated", + "version": "1.11.99.5", + "source": { + "type": "git", + "url": "https://github.com/composer/package-versions-deprecated.git", + "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/package-versions-deprecated/zipball/b4f54f74ef3453349c24a845d22392cd31e65f1d", + "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1.0 || ^2.0", + "php": "^7 || ^8" + }, + "replace": { + "ocramius/package-versions": "1.11.99" + }, + "require-dev": { + "composer/composer": "^1.9.3 || ^2.0@dev", + "ext-zip": "^1.13", + "phpunit/phpunit": "^6.5 || ^7" + }, + "type": "composer-plugin", + "extra": { + "class": "PackageVersions\\Installer", + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "PackageVersions\\": "src/PackageVersions" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + } + ], + "description": "Composer plugin that provides efficient querying for installed package versions (no runtime IO)", + "support": { + "issues": "https://github.com/composer/package-versions-deprecated/issues", + "source": "https://github.com/composer/package-versions-deprecated/tree/1.11.99.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-01-17T14:14:24+00:00" + }, + { + "name": "dnoegel/php-xdg-base-dir", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/dnoegel/php-xdg-base-dir.git", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~7.0|~6.0|~5.0|~4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "XdgBaseDir\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "implementation of xdg base directory specification for php", + "support": { + "issues": "https://github.com/dnoegel/php-xdg-base-dir/issues", + "source": "https://github.com/dnoegel/php-xdg-base-dir/tree/v0.1.1" + }, + "time": "2019-12-04T15:06:13+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", + "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.22" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.4.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-03-03T08:28:38+00:00" + }, + { + "name": "felixfbecker/advanced-json-rpc", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/felixfbecker/php-advanced-json-rpc.git", + "reference": "b5f37dbff9a8ad360ca341f3240dc1c168b45447" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/felixfbecker/php-advanced-json-rpc/zipball/b5f37dbff9a8ad360ca341f3240dc1c168b45447", + "reference": "b5f37dbff9a8ad360ca341f3240dc1c168b45447", + "shasum": "" + }, + "require": { + "netresearch/jsonmapper": "^1.0 || ^2.0 || ^3.0 || ^4.0", + "php": "^7.1 || ^8.0", + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "AdvancedJsonRpc\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Felix Becker", + "email": "felix.b@outlook.com" + } + ], + "description": "A more advanced JSONRPC implementation", + "support": { + "issues": "https://github.com/felixfbecker/php-advanced-json-rpc/issues", + "source": "https://github.com/felixfbecker/php-advanced-json-rpc/tree/v3.2.1" + }, + "time": "2021-06-11T22:34:44+00:00" + }, + { + "name": "felixfbecker/language-server-protocol", + "version": "v1.5.2", + "source": { + "type": "git", + "url": "https://github.com/felixfbecker/php-language-server-protocol.git", + "reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/6e82196ffd7c62f7794d778ca52b69feec9f2842", + "reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/phpstan": "*", + "squizlabs/php_codesniffer": "^3.1", + "vimeo/psalm": "^4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "LanguageServerProtocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Felix Becker", + "email": "felix.b@outlook.com" + } + ], + "description": "PHP classes for the Language Server Protocol", + "keywords": [ + "language", + "microsoft", + "php", + "server" + ], + "support": { + "issues": "https://github.com/felixfbecker/php-language-server-protocol/issues", + "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.2" + }, + "time": "2022-03-02T22:36:06+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614", + "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3,<3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2022-03-03T13:19:32+00:00" + }, + { + "name": "netresearch/jsonmapper", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/cweiske/jsonmapper.git", + "reference": "8bbc021a8edb2e4a7ea2f8ad4fa9ec9dce2fcb8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8bbc021a8edb2e4a7ea2f8ad4fa9ec9dce2fcb8d", + "reference": "8bbc021a8edb2e4a7ea2f8ad4fa9ec9dce2fcb8d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0", + "squizlabs/php_codesniffer": "~3.5" + }, + "type": "library", + "autoload": { + "psr-0": { + "JsonMapper": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "OSL-3.0" + ], + "authors": [ + { + "name": "Christian Weiske", + "email": "cweiske@cweiske.de", + "homepage": "http://github.com/cweiske/jsonmapper/", + "role": "Developer" + } + ], + "description": "Map nested JSON structures onto PHP classes", + "support": { + "email": "cweiske@cweiske.de", + "issues": "https://github.com/cweiske/jsonmapper/issues", + "source": "https://github.com/cweiske/jsonmapper/tree/v4.0.0" + }, + "time": "2020-12-01T19:48:11+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v4.15.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/0ef6c55a3f47f89d7a374e6f835197a0b5fcf900", + "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.0" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.1" + }, + "time": "2022-09-04T07:30:47+00:00" + }, + { + "name": "openlss/lib-array2xml", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/nullivex/lib-array2xml.git", + "reference": "a91f18a8dfc69ffabe5f9b068bc39bb202c81d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nullivex/lib-array2xml/zipball/a91f18a8dfc69ffabe5f9b068bc39bb202c81d90", + "reference": "a91f18a8dfc69ffabe5f9b068bc39bb202c81d90", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "type": "library", + "autoload": { + "psr-0": { + "LSS": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Bryan Tong", + "email": "bryan@nullivex.com", + "homepage": "https://www.nullivex.com" + }, + { + "name": "Tony Butler", + "email": "spudz76@gmail.com", + "homepage": "https://www.nullivex.com" + } + ], + "description": "Array2XML conversion library credit to lalit.org", + "homepage": "https://www.nullivex.com", + "keywords": [ + "array", + "array conversion", + "xml", + "xml conversion" + ], + "support": { + "issues": "https://github.com/nullivex/lib-array2xml/issues", + "source": "https://github.com/nullivex/lib-array2xml/tree/master" + }, + "time": "2019-03-29T20:06:56+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.3" + }, + "time": "2021-07-20T11:28:43+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "622548b623e81ca6d78b721c5e029f4ce664f170" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170", + "reference": "622548b623e81ca6d78b721c5e029f4ce664f170", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.3", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.2", + "psalm/phar": "^4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "account@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0" + }, + "time": "2021-10-19T17:43:47+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "77a32518733312af16a44300404e945338981de3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/77a32518733312af16a44300404e945338981de3", + "reference": "77a32518733312af16a44300404e945338981de3", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "psalm/phar": "^4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.1" + }, + "time": "2022-03-15T21:29:03+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.17", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "aa94dc41e8661fe90c7316849907cba3007b10d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/aa94dc41e8661fe90c7316849907cba3007b10d8", + "reference": "aa94dc41e8661fe90c7316849907cba3007b10d8", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.14", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.3", + "phpunit/php-text-template": "^2.0.2", + "sebastian/code-unit-reverse-lookup": "^2.0.2", + "sebastian/complexity": "^2.0", + "sebastian/environment": "^5.1.2", + "sebastian/lines-of-code": "^1.0.3", + "sebastian/version": "^3.0.1", + "theseer/tokenizer": "^1.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcov": "*", + "ext-xdebug": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.17" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-08-30T12:24:04+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.5.25", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d", + "reference": "3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.3.1", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.10.1", + "phar-io/manifest": "^2.0.3", + "phar-io/version": "^3.0.2", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.13", + "phpunit/php-file-iterator": "^3.0.5", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.3", + "phpunit/php-timer": "^5.0.2", + "sebastian/cli-parser": "^1.0.1", + "sebastian/code-unit": "^1.0.6", + "sebastian/comparator": "^4.0.8", + "sebastian/diff": "^4.0.3", + "sebastian/environment": "^5.1.3", + "sebastian/exporter": "^4.0.5", + "sebastian/global-state": "^5.0.1", + "sebastian/object-enumerator": "^4.0.3", + "sebastian/resource-operations": "^3.0.3", + "sebastian/type": "^3.2", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "*", + "ext-xdebug": "*" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.25" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2022-09-25T03:44:45+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:08:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T12:41:17+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.7", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:52:27+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:10:38+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-04-03T09:37:03+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T06:03:37+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-02-14T08:28:10+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.6", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-11-28T06:42:11+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:17:30+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "issues": "https://github.com/sebastianbergmann/resource-operations/issues", + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:45:17+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", + "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-12T14:47:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "simplesamlphp/simplesamlphp-test-framework", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/simplesamlphp-test-framework.git", + "reference": "70a601f41aebb00820b168c24f4b8177f414fc18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp-test-framework/zipball/70a601f41aebb00820b168c24f4b8177f414fc18", + "reference": "70a601f41aebb00820b168c24f4b8177f414fc18", + "shasum": "" + }, + "require": { + "php": ">=7.4|^8.0", + "phpunit/phpunit": "^8.5|^9.5", + "squizlabs/php_codesniffer": "^3.6", + "symfony/phpunit-bridge": "^6.0", + "vimeo/psalm": "^4.20|^5.0.0-beta1" + }, + "require-dev": { + "ext-curl": "*", + "simplesamlphp/simplesamlphp": "dev-master" + }, + "bin": [ + "bin/check-syntax-json.sh", + "bin/check-syntax-php.sh", + "bin/check-syntax-xml.sh", + "bin/check-syntax-yaml.sh" + ], + "type": "project", + "autoload": { + "psr-4": { + "SimpleSAML\\TestUtils\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Tim van Dijen", + "email": "tvdijen@gmail.com" + } + ], + "description": "Test framework for SimpleSAMLphp and related repositories ", + "keywords": [ + "test-framework" + ], + "support": { + "issues": "https://github.com/simplesamlphp/simplesamlphp-test-framework/issues", + "source": "https://github.com/simplesamlphp/simplesamlphp-test-framework" + }, + "time": "2022-05-15T10:37:25+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.7.1", + "source": { + "type": "git", + "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", + "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619", + "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "bin": [ + "bin/phpcs", + "bin/phpcbf" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "lead" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", + "source": "https://github.com/squizlabs/PHP_CodeSniffer", + "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + }, + "time": "2022-06-18T07:21:10+00:00" + }, + { + "name": "symfony/phpunit-bridge", + "version": "v6.1.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/phpunit-bridge.git", + "reference": "75c2fa71d049c1f48e39d208c0cefba97e66335a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/75c2fa71d049c1f48e39d208c0cefba97e66335a", + "reference": "75c2fa71d049c1f48e39d208c0cefba97e66335a", + "shasum": "" + }, + "require": { + "php": ">=7.1.3" + }, + "conflict": { + "phpunit/phpunit": "<7.5|9.1.2" + }, + "require-dev": { + "symfony/deprecation-contracts": "^2.1|^3.0", + "symfony/error-handler": "^5.4|^6.0" + }, + "suggest": { + "symfony/error-handler": "For tracking deprecated interfaces usages at runtime with DebugClassLoader" + }, + "bin": [ + "bin/simple-phpunit" + ], + "type": "symfony-bridge", + "extra": { + "thanks": { + "name": "phpunit/phpunit", + "url": "https://github.com/sebastianbergmann/phpunit" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Bridge\\PhpUnit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides utilities for PHPUnit, especially user deprecation notices management", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/phpunit-bridge/tree/v6.1.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-07-28T13:40:41+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", + "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2021-07-28T10:34:58+00:00" + }, + { + "name": "vimeo/psalm", + "version": "4.27.0", + "source": { + "type": "git", + "url": "https://github.com/vimeo/psalm.git", + "reference": "faf106e717c37b8c81721845dba9de3d8deed8ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/faf106e717c37b8c81721845dba9de3d8deed8ff", + "reference": "faf106e717c37b8c81721845dba9de3d8deed8ff", + "shasum": "" + }, + "require": { + "amphp/amp": "^2.4.2", + "amphp/byte-stream": "^1.5", + "composer/package-versions-deprecated": "^1.8.0", + "composer/semver": "^1.4 || ^2.0 || ^3.0", + "composer/xdebug-handler": "^1.1 || ^2.0 || ^3.0", + "dnoegel/php-xdg-base-dir": "^0.1.1", + "ext-ctype": "*", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-tokenizer": "*", + "felixfbecker/advanced-json-rpc": "^3.0.3", + "felixfbecker/language-server-protocol": "^1.5", + "netresearch/jsonmapper": "^1.0 || ^2.0 || ^3.0 || ^4.0", + "nikic/php-parser": "^4.13", + "openlss/lib-array2xml": "^1.0", + "php": "^7.1|^8", + "sebastian/diff": "^3.0 || ^4.0", + "symfony/console": "^3.4.17 || ^4.1.6 || ^5.0 || ^6.0", + "symfony/polyfill-php80": "^1.25", + "webmozart/path-util": "^2.3" + }, + "provide": { + "psalm/psalm": "self.version" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "brianium/paratest": "^4.0||^6.0", + "ext-curl": "*", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpdocumentor/reflection-docblock": "^5", + "phpmyadmin/sql-parser": "5.1.0||dev-master", + "phpspec/prophecy": ">=1.9.0", + "phpunit/phpunit": "^9.0", + "psalm/plugin-phpunit": "^0.16", + "slevomat/coding-standard": "^7.0", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.3 || ^5.0 || ^6.0", + "weirdan/prophecy-shim": "^1.0 || ^2.0" + }, + "suggest": { + "ext-curl": "In order to send data to shepherd", + "ext-igbinary": "^2.0.5 is required, used to serialize caching data" + }, + "bin": [ + "psalm", + "psalm-language-server", + "psalm-plugin", + "psalm-refactor", + "psalter" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev", + "dev-3.x": "3.x-dev", + "dev-2.x": "2.x-dev", + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php", + "src/spl_object_id.php" + ], + "psr-4": { + "Psalm\\": "src/Psalm/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthew Brown" + } + ], + "description": "A static analysis tool for finding errors in PHP applications", + "keywords": [ + "code", + "inspection", + "php" + ], + "support": { + "issues": "https://github.com/vimeo/psalm/issues", + "source": "https://github.com/vimeo/psalm/tree/4.27.0" + }, + "time": "2022-08-31T13:47:09+00:00" + }, + { + "name": "webmozart/path-util", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/webmozart/path-util.git", + "reference": "d939f7edc24c9a1bb9c0dee5cb05d8e859490725" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozart/path-util/zipball/d939f7edc24c9a1bb9c0dee5cb05d8e859490725", + "reference": "d939f7edc24c9a1bb9c0dee5cb05d8e859490725", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "webmozart/assert": "~1.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.6", + "sebastian/version": "^1.0.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\PathUtil\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "A robust cross-platform utility for normalizing, comparing and modifying file paths.", + "support": { + "issues": "https://github.com/webmozart/path-util/issues", + "source": "https://github.com/webmozart/path-util/tree/2.3.0" + }, + "abandoned": "symfony/filesystem", + "time": "2015-12-17T08:42:14+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "simplesamlphp/simplesamlphp": 10 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^7.4 || ^8.0", + "ext-pdo": "*", + "ext-pdo_sqlite": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/config-templates/module_accounting.php b/config-templates/module_accounting.php new file mode 100644 index 0000000000000000000000000000000000000000..828725be3cc9d61ade4860ca5de3b2669b946959 --- /dev/null +++ b/config-templates/module_accounting.php @@ -0,0 +1,206 @@ +<?php + +declare(strict_types=1); + +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Providers; +use SimpleSAML\Module\accounting\Stores; +use SimpleSAML\Module\accounting\Trackers; + +$config = [ + /** + * User ID attribute, one that is always available and that is unique to all users. + * If this attribute is not available, accounting will not be performed for that user. + * + * Examples: + * urn:oasis:names:tc:SAML:attribute:subject-id + * eduPersonTargetedID + * eduPersonPrincipalName + * eduPersonUniqueID + */ + ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME => 'urn:oasis:names:tc:SAML:attribute:subject-id', + + /** + * Default authentication source which will be used when authenticating users in SimpleSAMLphp Profile Page. + */ + ModuleConfiguration::OPTION_DEFAULT_AUTHENTICATION_SOURCE => 'default-sp', + + /** + * Accounting processing type. There are two possible types: 'synchronous' and 'asynchronous'. + */ + ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE => + /** + * Synchronous option, meaning accounting processing will be performed during authentication itself + * (slower authentication). + */ + ModuleConfiguration\AccountingProcessingType::VALUE_SYNCHRONOUS, + /** + * Asynchronous option, meaning for each authentication event a new job will be created for later processing + * (faster authentication, but requires setting up job storage and a cron entry). + */ + //ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS, + + /** + * Jobs store class. In case of the 'asynchronous' accounting processing type, this determines which class + * will be used to store jobs. The class must implement Stores\Interfaces\JobsStoreInterface. + */ + ModuleConfiguration::OPTION_JOBS_STORE => + /** + * Default jobs store class which expects Doctrine DBAL compatible connection to be set below. + */ + Stores\Jobs\DoctrineDbal\Store::class, + /** + * PhpRedis class Redis jobs store. Expects class Redis compatible connection to be set bellow. + * Note: PhpRedis must be installed: https://github.com/phpredis/phpredis#installation + */ + //Stores\Jobs\PhpRedis\RedisStore::class, + + /** + * Default data tracker and provider to be used for accounting and as a source for data display in SSP UI. + * This class must implement Trackers\Interfaces\AuthenticationDataTrackerInterface and + * Providers\Interfaces\AuthenticationDataProviderInterface + */ + ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER => + /** + * Track each authentication event for idp / sp / user combination, and any change in idp / sp metadata or + * released user attributes. Each authentication event record will have data used and released at the + * time of the authentication event (versioned idp / sp / user data). This tracker can also be + * used as an authentication data provider. It expects Doctrine DBAL compatible connection + * to be set below. Internally it uses store class + * Stores\Data\DoctrineDbal\DoctrineDbal\Versioned\Store::class. + */ + Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class, + + /** + * Additional trackers to run besides default data tracker. These trackers will typically only process and + * persist authentication data to proper data store, and won't be used to display data in SSP UI. + * These tracker classes must implement Trackers\Interfaces\AuthenticationDataTrackerInterface. + */ + ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [ + // tracker-class + ], + + /** + * Map of classes (stores, trackers, providers, ...) and connection keys, which defines which connections will + * be used. Value for connection key can be string, or it can be an array with two connection types as keys: + * master or slave. Master connection is single connection which will be used to write data to, and it + * must be set. If no slave connections are set, master will also be used to read data from. Slave + * connections are defined as array of strings. If slave connections are set, random one will + * be picked to read data from. + */ + ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [ + /** + * Connection key to be used by jobs store class. + */ + Stores\Jobs\DoctrineDbal\Store::class => 'doctrine_dbal_pdo_mysql', + Stores\Jobs\PhpRedis\RedisStore::class => 'phpredis_class_redis', + /** + * Connection key to be used by this data tracker and provider. + */ + Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class => [ + ModuleConfiguration\ConnectionType::MASTER => 'doctrine_dbal_pdo_mysql', + ModuleConfiguration\ConnectionType::SLAVE => [ + 'doctrine_dbal_pdo_mysql', + ], + ], + ], + + /** + * Connections and their parameters. + */ + ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [ + /** + * Examples for Doctrine DBAL compatible mysql and sqlite connection parameters are provided below (more info + * on https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html). + * There are additional parameters for: table prefix. + */ + 'doctrine_dbal_pdo_mysql' => [ + 'driver' => 'pdo_mysql', // (string): The built-in driver implementation to use. + 'user' => 'user', // (string): Username to use when connecting to the database. + 'password' => 'password', // (string): Password to use when connecting to the database. + 'host' => 'host', // (string): Hostname of the database to connect to. + 'port' => 3306, // (integer): Port of the database to connect to. + 'dbname' => 'dbname', // (string): Name of the database/schema to connect to. + //'unix_socket' => 'unix_socet', // (string): Name of the socket used to connect to the database. + 'charset' => 'utf8', // (string): The charset used when connecting to the database. + //'url' => 'mysql://user:secret@localhost/mydb?charset=utf8', // ...alternative way of providing parameters. + // Additional parameters not originally available in Doctrine DBAL + 'table_prefix' => '', // (string): Prefix for each table. + ], + 'doctrine_dbal_pdo_sqlite' => [ + 'driver' => 'pdo_sqlite', // (string): The built-in driver implementation to use. + 'path' => '/path/to/db.sqlite', // (string): The filesystem path to the database file. + // Mutually exclusive with memory. path takes precedence. + 'memory' => false, // (boolean): True if the SQLite database should be in-memory (non-persistent). + // Mutually exclusive with path. path takes precedence. + //'url' => 'sqlite:////path/to/db.sqlite // ...alternative way of providing path parameter. + //'url' => 'sqlite:///:memory:' // ...alternative way of providing memory parameter. + // Additional parameters not originally available in Doctrine DBAL + 'table_prefix' => '', // (string): Prefix for each table. + ], + /** + * Example for PhpRedis class Redis (https://github.com/phpredis/phpredis#class-redis). + */ + 'phpredis_class_redis' => [ + 'host' => '127.0.0.1', // (string): can be a host, or the path to a unix domain socket. + 'port' => 6379, // (int): default port is 6379, should be -1 for unix domain socket. + 'connectTimeout' => 1, // (float): value in seconds (default is 0 meaning unlimited). + //'retryInterval' => 500, // (int): value in milliseconds (optional, default 0) + //'readTimeout' => 0, // (float): value in seconds (default is 0 meaning unlimited) + 'auth' => ['phpredis', 'phpredis'], // (mixed): authentication information + 'keyPrefix' => 'ssp_accounting:' + ], + ], + + /** + * Job runner fine-grained configuration options. + * + * Maximum execution time for the job runner. You can use this option to limit job runner activity by combining + * when the job runner will run (using cron configuration) and how long the job runner will be active + * (execution time). This can be null, meaning it will run indefinitely, or can be set as a duration + * for DateInterval, examples being below. Note that when the job runner is run using Cron user + * interface in SimpleSAMLphp, the duration will be taken from the 'max_execution_time' ini + * setting, and will override this setting if ini setting is shorter. + * @see https://www.php.net/manual/en/dateinterval.construct.php + */ + ModuleConfiguration::OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME => null, + //ModuleConfiguration::OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME => 'PT9M', // 9 minutes + //ModuleConfiguration::OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME => 'PT59M', // 59 minutes + //ModuleConfiguration::OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME => 'P1D', // 1 day + + /** + * Number of processed jobs after which the job runner should take a 1-second pause. + * + * This option was introduced so that the job runner can act in a more resource friendly fashion when facing + * backend store. If the value is null, there will be no pause. + */ + ModuleConfiguration::OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED => 10, + + /** + * Tracker data retention policy. + * + * Determines how long the tracked data will be stored. If null, data will be stored indefinitely. Otherwise, it + * can be set as a duration for DateInterval, examples being below. For this to work, a cron tag must also + * be configured. + */ + ModuleConfiguration::OPTION_TRACKER_DATA_RETENTION_POLICY => null, + //ModuleConfiguration::OPTION_TRACKER_DATA_RETENTION_POLICY => 'P30D', // 30 days + //ModuleConfiguration::OPTION_TRACKER_DATA_RETENTION_POLICY => 'P6M', // 6 months + //ModuleConfiguration::OPTION_TRACKER_DATA_RETENTION_POLICY => 'P1Y', // 1 year + + + /** + * Cron tags. + * + * Job runner tag designates the cron tag to use when running accounting jobs. Make sure to add this tag to + * the cron module configuration in case of the 'asynchronous' accounting processing type. + */ + ModuleConfiguration::OPTION_CRON_TAG_FOR_JOB_RUNNER => 'accounting_job_runner', + + /** + * Tracker data retention policy tag designates the cron tag to use for enforcing data retention policy. Make sure + * to add this tag to the cron module configuration if data retention policy is different from null. + */ + ModuleConfiguration::OPTION_CRON_TAG_FOR_TRACKER_DATA_RETENTION_POLICY => + 'accounting_tracker_data_retention_policy', +]; diff --git a/hooks/hook_adminmenu.php b/hooks/hook_adminmenu.php new file mode 100644 index 0000000000000000000000000000000000000000..cb7b83be42f8f3b54cd341bbbe3fd8618cd29827 --- /dev/null +++ b/hooks/hook_adminmenu.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +use SimpleSAML\Locale\Translate; +use SimpleSAML\Module\accounting\Helpers\ModuleRoutesHelper; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +function accounting_hook_adminmenu(\SimpleSAML\XHTML\Template &$template): void +{ + $menuKey = 'menu'; + + $moduleRoutesHelper = new ModuleRoutesHelper(); + + $profilePageEntry = [ + ModuleConfiguration::MODULE_NAME => [ + 'url' => $moduleRoutesHelper->getUrl(ModuleRoutesHelper::PATH_USER_PERSONAL_DATA), + 'name' => Translate::noop('Profile Page'), + ], + ]; + + if (!isset($template->data[$menuKey]) || !is_array($template->data[$menuKey])) { + return; + } + + // Use array_splice to put our entry before the "Log out" entry. + array_splice($template->data[$menuKey], -1, 0, $profilePageEntry); + + $template->getLocalization()->addModuleDomain(ModuleConfiguration::MODULE_NAME); +} \ No newline at end of file diff --git a/hooks/hook_configpage.php b/hooks/hook_configpage.php new file mode 100644 index 0000000000000000000000000000000000000000..a2b95135f9c84a3f0d5c321e8e0b389837fdee1b --- /dev/null +++ b/hooks/hook_configpage.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +use SimpleSAML\Locale\Translate; +use SimpleSAML\Module\accounting\Helpers\ModuleRoutesHelper; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\XHTML\Template; + +function accounting_hook_configpage(Template &$template): void +{ + $moduleRoutesHelper = new ModuleRoutesHelper(); + + $dataLinksKey = 'links'; + + if (!isset($template->data[$dataLinksKey]) || !is_array($template->data[$dataLinksKey])) { + return; + } + + $template->data[$dataLinksKey][] = [ + 'href' => $moduleRoutesHelper->getUrl(ModuleRoutesHelper::PATH_ADMIN_CONFIGURATION_STATUS), + 'text' => Translate::noop('Accounting configuration status'), + ]; + + $template->getLocalization()->addModuleDomain(ModuleConfiguration::MODULE_NAME); +} diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php new file mode 100644 index 0000000000000000000000000000000000000000..ee6c7c2203a19a3cc82f90b1b93c357109494b4a --- /dev/null +++ b/hooks/hook_cron.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +use Psr\Log\LoggerInterface; +use SimpleSAML\Configuration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Services\JobRunner; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\Logger; +use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder; + +function accounting_hook_cron(array &$cronInfo): void +{ + $moduleConfiguration = new ModuleConfiguration(); + $logger = new Logger(); + + /** @var ?string $currentCronTag */ + $currentCronTag = $cronInfo['tag'] ?? null; + + if (!isset($cronInfo['summary']) || !is_array($cronInfo['summary'])) { + $cronInfo['summary'] = []; + } + + /** + * Job runner handling. + */ + $cronTagForJobRunner = $moduleConfiguration->getCronTagForJobRunner(); + try { + if ($currentCronTag === $cronTagForJobRunner) { + $state = (new JobRunner($moduleConfiguration, Configuration::getConfig()))->run(); + foreach ($state->getStatusMessages() as $statusMessage) { + $cronInfo['summary'][] = $statusMessage; + } + $message = sprintf( + 'Job processing finished with %s successful jobs, %s failed jobs; total: %s.', + $state->getSuccessfulJobsProcessed(), + $state->getFailedJobsProcessed(), + $state->getTotalJobsProcessed() + ); + $cronInfo['summary'][] = $message; + } + } catch (Throwable $exception) { + $message = 'Job runner error: ' . $exception->getMessage(); + $cronInfo['summary'][] = $message; + } + + if (!isset($cronInfo['summary']) || !is_array($cronInfo['summary'])) { + $cronInfo['summary'] = []; + } + + /** + * Tracker data retention policy handling. + */ + $cronTagForTrackerDataRetentionPolicy = $moduleConfiguration->getCronTagForTrackerDataRetentionPolicy(); + try { + if ( + $currentCronTag === $cronTagForTrackerDataRetentionPolicy && + ($retentionPolicy = $moduleConfiguration->getTrackerDataRetentionPolicy()) !== null + ) { + $helpersManager = new HelpersManager(); + $message = sprintf('Handling data retention policy.'); + $logger->info($message); + $cronInfo['summary'][] = $message; + handleDataRetentionPolicy($moduleConfiguration, $logger, $helpersManager, $retentionPolicy); + } + } catch (Throwable $exception) { + $message = 'Error enforcing tracker data retention policy: ' . $exception->getMessage(); + $cronInfo['summary'][] = $message; + } +} + +function handleDataRetentionPolicy( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + HelpersManager $helpersManager, + DateInterval $retentionPolicy +): void { + // Handle default data tracker and provider + (new AuthenticationDataTrackerBuilder($moduleConfiguration, $logger, $helpersManager)) + ->build($moduleConfiguration->getDefaultDataTrackerAndProviderClass()) + ->enforceDataRetentionPolicy($retentionPolicy); + + $additionalTrackers = $moduleConfiguration->getAdditionalTrackers(); + + foreach ($additionalTrackers as $tracker) { + (new AuthenticationDataTrackerBuilder($moduleConfiguration, $logger, $helpersManager)) + ->build($tracker) + ->enforceDataRetentionPolicy($retentionPolicy); + } +} \ No newline at end of file diff --git a/locales/en/LC_MESSAGES/accounting.po b/locales/en/LC_MESSAGES/accounting.po new file mode 100644 index 0000000000000000000000000000000000000000..371de5e881a72460fceaee164dada5c5c0df2dfa --- /dev/null +++ b/locales/en/LC_MESSAGES/accounting.po @@ -0,0 +1,18 @@ +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: SimpleSAMLphp 2.0.0\n" +"Report-Msgid-Bugs-To: simplesamlphp-translation@googlegroups.com\n" +"POT-Creation-Date: 2016-10-12 09:31+0200\n" +"PO-Revision-Date: 2022-01-09 12:14+0200\n" +"Last-Translator: Marko Ivancic <mivanci@srce.hr\n" +"Language: en\n" +"Language-Team: \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.3.4\n" + +msgid "Accounting" +msgstr "Accounting" diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000000000000000000000000000000000000..2ea895dff6c74f0e0b676ee3c3e41e75e3e9714c --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,17 @@ +<?xml version="1.0"?> +<ruleset name="SimpleSAMLphp accounting module ruleset"> + + <file>config-templates</file> + <file>src</file> + <file>tests</file> + <file>www</file> + + <!-- Use this to exclude paths. You can have multiple patterns --> + <!--<exclude-pattern>*/tests/*</exclude-pattern>--> + <!--<exclude-pattern>*/other/*</exclude-pattern>--> + <exclude-pattern>www/assets/*</exclude-pattern> + + <!-- This is the rule we inherit from. If you want to exlude some specific rules, see the docs on how to do that --> + <rule ref="PSR12"/> +</ruleset> + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000000000000000000000000000000000000..cd2432576de15fa1f01e7b022850451dc13ba416 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd" + bootstrap="vendor/autoload.php" + cacheResultFile="build/.phpunit.cache/test-results" + executionOrder="depends,defects" + forceCoversAnnotation="true" + beStrictAboutCoversAnnotation="true" + beStrictAboutOutputDuringTests="true" + beStrictAboutTodoAnnotatedTests="true" + convertDeprecationsToExceptions="true" + failOnRisky="true" + failOnWarning="true" + verbose="true"> + + <testsuites> + <testsuite name="default"> + <directory>tests</directory> + </testsuite> + </testsuites> + + <coverage cacheDirectory="build/.phpunit.cache/code-coverage" + processUncoveredFiles="true"> + <include> + <directory suffix=".php">src</directory> + </include> + + <report> + <clover outputFile="build/coverage/clover.xml"/> + <html outputDirectory="build/coverage/html"/> + <text outputFile="php://stdout"/> + </report> + </coverage> + + <logging> + <junit outputFile="build/logs/junit.xml"/> + </logging> + + <php> + <env name="SIMPLESAMLPHP_CONFIG_DIR" value="tests/config-templates"/> + </php> +</phpunit> diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000000000000000000000000000000000000..ccad21aa75b84382afc60f916d787ce5105425c1 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,41 @@ +<?xml version="1.0"?> +<psalm + errorLevel="1" + resolveFromConfigFile="true" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="https://getpsalm.org/schema/config" + xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" +> + <projectFiles> + <directory name="src" /> + <directory name="config-templates" /> + <directory name="tests" /> + <directory name="www" /> + <directory name="hooks" /> + + <ignoreFiles> + <directory name="vendor" /> + </ignoreFiles> + </projectFiles> + + <issueHandlers> + <!-- Ignore the fact that $config variable is not used in particular config files. --> + <UnusedVariable> + <errorLevel type="suppress"> + <directory name="config-templates" /> + <directory name="tests/config-templates" /> + <directory name="tests/attributemap" /> + </errorLevel> + </UnusedVariable> + + <!-- + Ignore PropertyNotSetInConstructor for phpunit tests. For example, this will ignore things like + "Property SomeTestClass::$backupStaticAttributes is not defined in constructor of SomeTestClass..." + --> + <PropertyNotSetInConstructor> + <errorLevel type="suppress"> + <directory name="tests" /> + </errorLevel> + </PropertyNotSetInConstructor> + </issueHandlers> +</psalm> diff --git a/routing/routes/routes.yml b/routing/routes/routes.yml new file mode 100644 index 0000000000000000000000000000000000000000..79241fb266c79420e0d6706d41dbd1db422206df --- /dev/null +++ b/routing/routes/routes.yml @@ -0,0 +1,28 @@ + +# TODO mivanci delete test route +accounting-test: + path: /test + defaults: { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\Test::test' } + +accounting-admin-configuration-status: + path: /admin/configuration/status + defaults: { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\Admin\Configuration::status' } + +accounting-user-personal-data: + path: /user/personal-data + defaults: { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\User\Profile::personalData' } + +accounting-user-connected-organizations: + path: /user/connected-organizations + defaults: { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\User\Profile::connectedOrganizations' } + +accounting-user-activity: + path: /user/activity + defaults: { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\User\Profile::activity' } + +accounting-user-logout: + path: /user/logout + methods: + - GET + - POST + defaults: { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\User\Profile::logout' } diff --git a/routing/services/services.yml b/routing/services/services.yml new file mode 100644 index 0000000000000000000000000000000000000000..a85987f93c7e37693b49911bbfa5f223faf3d668 --- /dev/null +++ b/routing/services/services.yml @@ -0,0 +1,19 @@ +services: + # default configuration for services in *this* file + _defaults: + autowire: true + public: false + bind: + Psr\Log\LoggerInterface: '@accounting.logger' + + # Services + SimpleSAML\Module\accounting\: + resource: '../../src/*' + exclude: '../../src/{Http/Controllers}' + # Service aliases + accounting.logger: + class: SimpleSAML\Module\accounting\Services\Logger + # Controllers + SimpleSAML\Module\accounting\Http\Controllers\: + resource: '../../src/Http/Controllers/*' + tags: ['controller.service_arguments'] \ No newline at end of file diff --git a/src/Auth/Process/Accounting.php b/src/Auth/Process/Accounting.php new file mode 100644 index 0000000000000000000000000000000000000000..817fa9e6636cb0e87eb86b9b1e8291f4c103dfac --- /dev/null +++ b/src/Auth/Process/Accounting.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Auth\Process; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Auth\ProcessingFilter; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Services\Logger; +use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder; +use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder; + +class Accounting extends ProcessingFilter +{ + protected ModuleConfiguration $moduleConfiguration; + protected JobsStoreBuilder $jobsStoreBuilder; + protected LoggerInterface $logger; + protected AuthenticationDataTrackerBuilder $authenticationDataTrackerBuilder; + protected HelpersManager $helpersManager; + + /** + * @param array $config + * @param mixed $reserved + * @param ModuleConfiguration|null $moduleConfiguration + * @param LoggerInterface|null $logger + * @param HelpersManager|null $helpersManager + * @param JobsStoreBuilder|null $jobsStoreBuilder + * @param AuthenticationDataTrackerBuilder|null $authenticationDataTrackerBuilder + */ + public function __construct( + array &$config, + $reserved, + ModuleConfiguration $moduleConfiguration = null, + LoggerInterface $logger = null, + HelpersManager $helpersManager = null, + JobsStoreBuilder $jobsStoreBuilder = null, + AuthenticationDataTrackerBuilder $authenticationDataTrackerBuilder = null + ) { + parent::__construct($config, $reserved); + + $this->moduleConfiguration = $moduleConfiguration ?? new ModuleConfiguration(); + $this->logger = $logger ?? new Logger(); + $this->helpersManager = $helpersManager ?? new HelpersManager(); + $this->jobsStoreBuilder = $jobsStoreBuilder ?? + new JobsStoreBuilder($this->moduleConfiguration, $this->logger, $this->helpersManager); + + $this->authenticationDataTrackerBuilder = $authenticationDataTrackerBuilder ?? + new AuthenticationDataTrackerBuilder($this->moduleConfiguration, $this->logger, $this->helpersManager); + } + + /** + */ + public function process(array &$state): void + { + try { + $authenticationEvent = new Event(new State($state)); + + if ($this->isAccountingProcessingTypeAsynchronous()) { + // Only create authentication event job for later processing... + $this->createAuthenticationEventJob($authenticationEvent); + return; + } + + // Accounting type is synchronous, so do the processing right away... + $configuredTrackers = array_merge( + [$this->moduleConfiguration->getDefaultDataTrackerAndProviderClass()], + $this->moduleConfiguration->getAdditionalTrackers() + ); + + foreach ($configuredTrackers as $tracker) { + ($this->authenticationDataTrackerBuilder->build($tracker))->process($authenticationEvent); + } + } catch (\Throwable $exception) { + $message = sprintf('Accounting error, skipping... Error was: %s.', $exception->getMessage()); + $this->logger->error($message, $state); + } + } + + protected function isAccountingProcessingTypeAsynchronous(): bool + { + return $this->moduleConfiguration->getAccountingProcessingType() === + ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS; + } + + /** + * @throws StoreException + */ + protected function createAuthenticationEventJob(Event $authenticationEvent): void + { + ($this->jobsStoreBuilder->build($this->moduleConfiguration->getJobsStoreClass())) + ->enqueue(new Event\Job($authenticationEvent)); + } +} diff --git a/src/Entities/Activity.php b/src/Entities/Activity.php new file mode 100644 index 0000000000000000000000000000000000000000..7371efd47f37c1f736017ec7a2f34260b9d9170a --- /dev/null +++ b/src/Entities/Activity.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities; + +use DateTimeImmutable; + +class Activity +{ + protected ServiceProvider $serviceProvider; + protected User $user; + protected DateTimeImmutable $happenedAt; + protected ?string $clientIpAddress; + + public function __construct( + ServiceProvider $serviceProvider, + User $user, + DateTimeImmutable $happenedAt, + ?string $clientIpAddress + ) { + $this->serviceProvider = $serviceProvider; + $this->user = $user; + $this->happenedAt = $happenedAt; + $this->clientIpAddress = $clientIpAddress; + } + + /** + * @return ServiceProvider + */ + public function getServiceProvider(): ServiceProvider + { + return $this->serviceProvider; + } + + /** + * @return User + */ + public function getUser(): User + { + return $this->user; + } + + /** + * @return DateTimeImmutable + */ + public function getHappenedAt(): DateTimeImmutable + { + return $this->happenedAt; + } + + /** + * @return string|null + */ + public function getClientIpAddress(): ?string + { + return $this->clientIpAddress; + } +} diff --git a/src/Entities/Activity/Bag.php b/src/Entities/Activity/Bag.php new file mode 100644 index 0000000000000000000000000000000000000000..7e91a1c9604527a43b3bcb5bf8e68ebc9d881a41 --- /dev/null +++ b/src/Entities/Activity/Bag.php @@ -0,0 +1,20 @@ +<?php + +namespace SimpleSAML\Module\accounting\Entities\Activity; + +use SimpleSAML\Module\accounting\Entities\Activity; + +class Bag +{ + protected array $activities = []; + + public function add(Activity $activity): void + { + $this->activities[] = $activity; + } + + public function getAll(): array + { + return $this->activities; + } +} diff --git a/src/Entities/Authentication/Event.php b/src/Entities/Authentication/Event.php new file mode 100644 index 0000000000000000000000000000000000000000..08c9e8c3c44e9889632b988b00c4ffae982a5305 --- /dev/null +++ b/src/Entities/Authentication/Event.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities\Authentication; + +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; + +class Event extends AbstractPayload +{ + protected State $state; + protected \DateTimeImmutable $happenedAt; + + public function __construct(State $state, \DateTimeImmutable $happenedAt = null) + { + $this->state = $state; + $this->happenedAt = $happenedAt ?? new \DateTimeImmutable(); + } + + public function getState(): State + { + return $this->state; + } + + public function getHappenedAt(): \DateTimeImmutable + { + return $this->happenedAt; + } +} diff --git a/src/Entities/Authentication/Event/Job.php b/src/Entities/Authentication/Event/Job.php new file mode 100644 index 0000000000000000000000000000000000000000..5cea7d987cba7e579bdf4e7096edf7d8f65d7ecb --- /dev/null +++ b/src/Entities/Authentication/Event/Job.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities\Authentication\Event; + +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; + +class Job extends AbstractJob +{ + public function getPayload(): Event + { + return $this->validatePayload($this->payload); + } + + public function setPayload(AbstractPayload $payload): void + { + $this->payload = $this->validatePayload($payload); + } + + protected function validatePayload(AbstractPayload $payload): Event + { + if (! ($payload instanceof Event)) { + throw new UnexpectedValueException('Event Job payload must be of type Event.'); + } + + return $payload; + } + + public function getType(): string + { + return self::class; + } +} diff --git a/src/Entities/Authentication/State.php b/src/Entities/Authentication/State.php new file mode 100644 index 0000000000000000000000000000000000000000..899b3e75d51c500111e729ca8d73293fee7b7a1a --- /dev/null +++ b/src/Entities/Authentication/State.php @@ -0,0 +1,198 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities\Authentication; + +use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Helpers\NetworkHelper; +use SimpleSAML\Module\accounting\Services\HelpersManager; + +class State +{ + public const KEY_ATTRIBUTES = 'Attributes'; + public const KEY_AUTHENTICATION_INSTANT = 'AuthnInstant'; + public const KEY_IDENTITY_PROVIDER_METADATA = 'IdPMetadata'; + public const KEY_SOURCE = 'Source'; + public const KEY_SERVICE_PROVIDER_METADATA = 'SPMetadata'; + public const KEY_DESTINATION = 'Destination'; + + public const KEY_ACCOUNTING = 'accounting'; + public const ACCOUNTING_KEY_CLIENT_IP_ADDRESS = 'client_ip_address'; + + protected string $identityProviderEntityId; + protected string $serviceProviderEntityId; + protected array $attributes; + protected \DateTimeImmutable $createdAt; + protected ?\DateTimeImmutable $authenticationInstant; + protected array $identityProviderMetadata; + protected array $serviceProviderMetadata; + protected ?string $clientIpAddress; + protected HelpersManager $helpersManager; + + public function __construct( + array $state, + \DateTimeImmutable $createdAt = null, + HelpersManager $helpersManager = null + ) { + $this->createdAt = $createdAt ?? new \DateTimeImmutable(); + $this->helpersManager = $helpersManager ?? new HelpersManager(); + + $this->identityProviderMetadata = $this->resolveIdentityProviderMetadata($state); + $this->identityProviderEntityId = $this->resolveIdentityProviderEntityId(); + $this->serviceProviderMetadata = $this->resolveServiceProviderMetadata($state); + $this->serviceProviderEntityId = $this->resolveServiceProviderEntityId(); + $this->attributes = $this->resolveAttributes($state); + $this->authenticationInstant = $this->resolveAuthenticationInstant($state); + $this->clientIpAddress = $this->resolveClientIpAddress($state); + } + + protected function resolveIdentityProviderEntityId(): string + { + if ( + !empty($this->identityProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID]) && + is_string($this->identityProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID]) + ) { + return $this->identityProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID]; + } + + throw new UnexpectedValueException('IdP metadata array does not contain entity ID.'); + } + + protected function resolveServiceProviderEntityId(): string + { + if ( + !empty($this->serviceProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID]) && + is_string($this->serviceProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID]) + ) { + return $this->serviceProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID]; + } + + throw new UnexpectedValueException('SP metadata array does not contain entity ID.'); + } + + protected function resolveAttributes(array $state): array + { + if (empty($state[self::KEY_ATTRIBUTES]) || !is_array($state[self::KEY_ATTRIBUTES])) { + throw new UnexpectedValueException('State array does not contain user attributes.'); + } + + return $state[self::KEY_ATTRIBUTES]; + } + + public function getIdentityProviderEntityId(): string + { + return $this->identityProviderEntityId; + } + + public function getServiceProviderEntityId(): string + { + return $this->serviceProviderEntityId; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttributeValue(string $attributeName): ?string + { + if (!empty($this->attributes[$attributeName]) && is_array($this->attributes[$attributeName])) { + return (string)reset($this->attributes[$attributeName]); + } + + return null; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + protected function resolveAuthenticationInstant(array $state): ?\DateTimeImmutable + { + if (empty($state[self::KEY_AUTHENTICATION_INSTANT])) { + return null; + } + + $authInstant = (string)$state[self::KEY_AUTHENTICATION_INSTANT]; + + try { + return new \DateTimeImmutable('@' . $authInstant); + } catch (\Throwable $exception) { + $message = sprintf( + 'Unable to create DateTimeImmutable using AuthInstant value \'%s\'. Error was: %s.', + $authInstant, + $exception->getMessage() + ); + throw new UnexpectedValueException($message); + } + } + + public function getAuthenticationInstant(): ?\DateTimeImmutable + { + return $this->authenticationInstant; + } + + protected function resolveIdentityProviderMetadata(array $state): array + { + if ( + !empty($state[self::KEY_IDENTITY_PROVIDER_METADATA]) && + is_array($state[self::KEY_IDENTITY_PROVIDER_METADATA]) + ) { + return $state[self::KEY_IDENTITY_PROVIDER_METADATA]; + } elseif (!empty($state[self::KEY_SOURCE]) && is_array($state[self::KEY_SOURCE])) { + return $state[self::KEY_SOURCE]; + } + + throw new UnexpectedValueException('State array does not contain IdP metadata.'); + } + + protected function resolveServiceProviderMetadata(array $state): array + { + if ( + !empty($state[self::KEY_SERVICE_PROVIDER_METADATA]) && + is_array($state[self::KEY_SERVICE_PROVIDER_METADATA]) + ) { + return $state[self::KEY_SERVICE_PROVIDER_METADATA]; + } elseif (!empty($state[self::KEY_DESTINATION]) && is_array($state[self::KEY_DESTINATION])) { + return $state[self::KEY_DESTINATION]; + } + + throw new UnexpectedValueException('State array does not contain SP metadata.'); + } + + /** + * @return array + */ + public function getIdentityProviderMetadata(): array + { + return $this->identityProviderMetadata; + } + + /** + * @return array + */ + public function getServiceProviderMetadata(): array + { + return $this->serviceProviderMetadata; + } + + protected function resolveClientIpAddress(array $state): ?string + { + return $this->helpersManager->getNetworkHelper()->resolveClientIpAddress( + isset($state[self::KEY_ACCOUNTING][self::ACCOUNTING_KEY_CLIENT_IP_ADDRESS]) ? + (string)$state[self::KEY_ACCOUNTING][self::ACCOUNTING_KEY_CLIENT_IP_ADDRESS] + : null + ); + } + + /** + * @return string|null + */ + public function getClientIpAddress(): ?string + { + return $this->clientIpAddress; + } +} diff --git a/src/Entities/Bases/AbstractJob.php b/src/Entities/Bases/AbstractJob.php new file mode 100644 index 0000000000000000000000000000000000000000..4b9380ea96efce220b7f9bad05c7fb341e7eede3 --- /dev/null +++ b/src/Entities/Bases/AbstractJob.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities\Bases; + +use DateTimeImmutable; +use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface; + +abstract class AbstractJob implements JobInterface +{ + protected AbstractPayload $payload; + protected ?int $id; + protected DateTimeImmutable $createdAt; + + public function __construct( + AbstractPayload $payload, + int $id = null, + DateTimeImmutable $createdAt = null + ) { + $this->setPayload($payload); + $this->id = $id; + $this->createdAt = $createdAt ?? new DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getPayload(): AbstractPayload + { + return $this->payload; + } + + public function setPayload(AbstractPayload $payload): void + { + $this->payload = $payload; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + abstract public function getType(): string; +} diff --git a/src/Entities/Bases/AbstractPayload.php b/src/Entities/Bases/AbstractPayload.php new file mode 100644 index 0000000000000000000000000000000000000000..f01d96b1b555293602f2fab8a032b5373da24fb0 --- /dev/null +++ b/src/Entities/Bases/AbstractPayload.php @@ -0,0 +1,9 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities\Bases; + +abstract class AbstractPayload +{ +} diff --git a/src/Entities/Bases/AbstractProvider.php b/src/Entities/Bases/AbstractProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..56c0927f39e52c3a1288b6d742aec30747b3e0a1 --- /dev/null +++ b/src/Entities/Bases/AbstractProvider.php @@ -0,0 +1,76 @@ +<?php + +namespace SimpleSAML\Module\accounting\Entities\Bases; + +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; + +abstract class AbstractProvider +{ + public const METADATA_KEY_NAME = 'name'; + public const METADATA_KEY_ENTITY_ID = 'entityid'; + public const METADATA_KEY_DESCRIPTION = 'description'; + + protected array $metadata; + protected string $entityId; + + public function __construct(array $metadata) + { + $this->metadata = $metadata; + $this->entityId = $this->resolveEntityId(); + } + + public function getMetadata(): array + { + return $this->metadata; + } + + public function getName(string $locale = 'en'): ?string + { + return $this->resolveOptionallyLocalizedString(self::METADATA_KEY_NAME, $locale); + } + + public function getEntityId(): string + { + return $this->entityId; + } + + public function getDescription(string $locale = 'en'): ?string + { + return $this->resolveOptionallyLocalizedString(self::METADATA_KEY_DESCRIPTION, $locale); + } + + + protected function resolveEntityId(): string + { + if ( + !empty($this->metadata[self::METADATA_KEY_ENTITY_ID]) && + is_string($this->metadata[self::METADATA_KEY_ENTITY_ID]) + ) { + return $this->metadata[self::METADATA_KEY_ENTITY_ID]; + } + + throw new UnexpectedValueException('Provider entity metadata does not contain entity ID.'); + } + + protected function resolveOptionallyLocalizedString(string $key, string $locale = 'en'): ?string + { + if (!isset($this->metadata[$key])) { + return null; + } + + // Check for non-localized version. + if (is_string($this->metadata[$key])) { + return $this->metadata[$key]; + } + + if ( + is_array($this->metadata[$key]) && + !empty($this->metadata[$key][$locale]) && + is_string($this->metadata[$key][$locale]) + ) { + return $this->metadata[$key][$locale]; + } + + return null; + } +} diff --git a/src/Entities/ConnectedServiceProvider.php b/src/Entities/ConnectedServiceProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..21bf0fc3a6c618c91faa71233d09e9b04d0ef9e5 --- /dev/null +++ b/src/Entities/ConnectedServiceProvider.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities; + +/** + * Represents a Service Provider to which a user has authenticated at least once. + */ +class ConnectedServiceProvider +{ + protected ServiceProvider $serviceProvider; + protected int $numberOfAuthentications; + protected \DateTimeImmutable $lastAuthenticationAt; + protected \DateTimeImmutable $firstAuthenticationAt; + protected User $user; + + /** + * TODO mivanci make sortable by name (or entity ID if not present), number of authns, last/first authn. + * @param ServiceProvider $serviceProvider + * @param int $numberOfAuthentications + * @param \DateTimeImmutable $lastAuthenticationAt + * @param \DateTimeImmutable $firstAuthenticationAt + * @param User $user + */ + public function __construct( + ServiceProvider $serviceProvider, + int $numberOfAuthentications, + \DateTimeImmutable $lastAuthenticationAt, + \DateTimeImmutable $firstAuthenticationAt, + User $user + ) { + $this->serviceProvider = $serviceProvider; + $this->numberOfAuthentications = $numberOfAuthentications; + $this->lastAuthenticationAt = $lastAuthenticationAt; + $this->firstAuthenticationAt = $firstAuthenticationAt; + $this->user = $user; + } + + /** + * @return ServiceProvider + */ + public function getServiceProvider(): ServiceProvider + { + return $this->serviceProvider; + } + + /** + * @return int + */ + public function getNumberOfAuthentications(): int + { + return $this->numberOfAuthentications; + } + + /** + * @return \DateTimeImmutable + */ + public function getLastAuthenticationAt(): \DateTimeImmutable + { + return $this->lastAuthenticationAt; + } + + /** + * @return \DateTimeImmutable + */ + public function getFirstAuthenticationAt(): \DateTimeImmutable + { + return $this->firstAuthenticationAt; + } + + /** + * @return User + */ + public function getUser(): User + { + return $this->user; + } +} diff --git a/src/Entities/ConnectedServiceProvider/Bag.php b/src/Entities/ConnectedServiceProvider/Bag.php new file mode 100644 index 0000000000000000000000000000000000000000..9507d8f45aa072d8c3cce369305fe9e417b7180d --- /dev/null +++ b/src/Entities/ConnectedServiceProvider/Bag.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; + +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; + +class Bag +{ + /** + * @var ConnectedServiceProvider[] + */ + protected array $connectedServiceProviders = []; + + public function addOrReplace(ConnectedServiceProvider $connectedServiceProvider): void + { + $spEntityId = $connectedServiceProvider->getServiceProvider()->getEntityId(); + + $this->connectedServiceProviders[$spEntityId] = $connectedServiceProvider; + } + + public function getAll(): array + { + return $this->connectedServiceProviders; + } +} diff --git a/src/Entities/GenericJob.php b/src/Entities/GenericJob.php new file mode 100644 index 0000000000000000000000000000000000000000..5031ba4a8975203e50691ae615752bf48a173631 --- /dev/null +++ b/src/Entities/GenericJob.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities; + +class GenericJob extends Bases\AbstractJob +{ + public function getType(): string + { + return self::class; + } +} diff --git a/src/Entities/IdentityProvider.php b/src/Entities/IdentityProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..3207e918717099fcd26e8681e0855c2b54746c64 --- /dev/null +++ b/src/Entities/IdentityProvider.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities; + +use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider; + +class IdentityProvider extends AbstractProvider +{ +} diff --git a/src/Entities/Interfaces/JobInterface.php b/src/Entities/Interfaces/JobInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..9578ea94d1c02611166db1e37ac9e0127d349b58 --- /dev/null +++ b/src/Entities/Interfaces/JobInterface.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities\Interfaces; + +use DateTimeImmutable; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; + +interface JobInterface +{ + public function getId(): ?int; + + public function getPayload(): AbstractPayload; + + public function setPayload(AbstractPayload $payload): void; + + public function getType(): string; + + public function getCreatedAt(): DateTimeImmutable; +} diff --git a/src/Entities/ServiceProvider.php b/src/Entities/ServiceProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..c5696c8f1f5301ac58f3057fc2163ca385534cda --- /dev/null +++ b/src/Entities/ServiceProvider.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities; + +use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider; + +class ServiceProvider extends AbstractProvider +{ +} diff --git a/src/Entities/User.php b/src/Entities/User.php new file mode 100644 index 0000000000000000000000000000000000000000..138bc4cb5f99501a15aea7b7fe23ffb4f601d9db --- /dev/null +++ b/src/Entities/User.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Entities; + +class User +{ + protected array $attributes; + + public function __construct(array $attributes) + { + $this->attributes = $attributes; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/src/Exceptions/Exception.php b/src/Exceptions/Exception.php new file mode 100644 index 0000000000000000000000000000000000000000..7a0306d210c88d215f326f2b591e86df49ed191b --- /dev/null +++ b/src/Exceptions/Exception.php @@ -0,0 +1,7 @@ +<?php + +namespace SimpleSAML\Module\accounting\Exceptions; + +class Exception extends \Exception +{ +} diff --git a/src/Exceptions/InvalidConfigurationException.php b/src/Exceptions/InvalidConfigurationException.php new file mode 100644 index 0000000000000000000000000000000000000000..034624ec83e27d795f703a7d66b92f57f75cbe99 --- /dev/null +++ b/src/Exceptions/InvalidConfigurationException.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Exceptions; + +use ValueError; + +class InvalidConfigurationException extends ValueError +{ +} diff --git a/src/Exceptions/InvalidValueException.php b/src/Exceptions/InvalidValueException.php new file mode 100644 index 0000000000000000000000000000000000000000..c4932850a481b43fa43d431281074d553bcdaedf --- /dev/null +++ b/src/Exceptions/InvalidValueException.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Exceptions; + +use ValueError; + +class InvalidValueException extends ValueError +{ +} diff --git a/src/Exceptions/StoreException.php b/src/Exceptions/StoreException.php new file mode 100644 index 0000000000000000000000000000000000000000..3e1b6772e307e438427d0a96a6a942031188a665 --- /dev/null +++ b/src/Exceptions/StoreException.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Exceptions; + +use Exception; + +class StoreException extends Exception +{ +} diff --git a/src/Exceptions/StoreException/MigrationException.php b/src/Exceptions/StoreException/MigrationException.php new file mode 100644 index 0000000000000000000000000000000000000000..377d06efc3c13d0e68b9dc5785f4c39dfbc38b91 --- /dev/null +++ b/src/Exceptions/StoreException/MigrationException.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Exceptions\StoreException; + +use SimpleSAML\Module\accounting\Exceptions\StoreException; + +class MigrationException extends StoreException +{ +} diff --git a/src/Exceptions/UnexpectedValueException.php b/src/Exceptions/UnexpectedValueException.php new file mode 100644 index 0000000000000000000000000000000000000000..22fde53e21b31720a2faf7fb754b41659dd3bd54 --- /dev/null +++ b/src/Exceptions/UnexpectedValueException.php @@ -0,0 +1,9 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Exceptions; + +class UnexpectedValueException extends \UnexpectedValueException +{ +} diff --git a/src/Helpers/ArrayHelper.php b/src/Helpers/ArrayHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..7aec51f9069bec8f7991b39d8e581ed5aeb646fc --- /dev/null +++ b/src/Helpers/ArrayHelper.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Helpers; + +class ArrayHelper +{ + public function recursivelySortByKey(array &$array): void + { + /** @psalm-suppress MixedAssignment */ + foreach ($array as &$value) { + if (is_array($value)) { + $this->recursivelySortByKey($value); + } + } + + ksort($array); + } +} diff --git a/src/Helpers/AttributesHelper.php b/src/Helpers/AttributesHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..8fc6806d02e6ddc926c12f2ec8a9594543009289 --- /dev/null +++ b/src/Helpers/AttributesHelper.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Helpers; + +class AttributesHelper +{ + /** + * Map files which translate attribute names to (more) user-friendly format. + */ + public const MAP_FILES_TO_NAME = ['facebook2name.php', 'linkedin2name.php', 'oid2name.php', 'openid2name.php', + 'removeurnprefix.php', 'twitter2name.php', 'urn2name.php', 'windowslive2name.php']; + + public static function getMergedAttributeMapForFiles(string $sspBaseDirectory, array $mapFiles): array + { + // This is the variable name used in map files. It is set to empty array by default, but later populated + // by each include of the map file. + $attributemap = []; + + $fullAttributeMap = []; + + /** @var string $mapFile */ + foreach ($mapFiles as $mapFile) { + $mapFilePath = $sspBaseDirectory . 'attributemap' . DIRECTORY_SEPARATOR . $mapFile; + if (! file_exists($mapFilePath)) { + continue; + } + include $mapFilePath; + $fullAttributeMap = array_merge($fullAttributeMap, $attributemap); + } + + return $fullAttributeMap; + } +} diff --git a/src/Helpers/DateTimeHelper.php b/src/Helpers/DateTimeHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..0fc5f25242105b35e58ee10257340b8dd7dcfdf2 --- /dev/null +++ b/src/Helpers/DateTimeHelper.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Helpers; + +class DateTimeHelper +{ + /** + * Convert date interval to seconds, interval being minimum 1 second. + * @param \DateInterval $dateInterval Minimum is 1 second. + * @return int + */ + public function convertDateIntervalToSeconds(\DateInterval $dateInterval): int + { + $reference = new \DateTimeImmutable(); + $endTime = $reference->add($dateInterval); + + $duration = $endTime->getTimestamp() - $reference->getTimestamp(); + + if ($duration < 1) { + $duration = 1; + } + + return $duration; + } +} diff --git a/src/Helpers/EnvironmentHelper.php b/src/Helpers/EnvironmentHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..b67f33c1b7e50ef6dbe8589a3a70d7c1b2d368ce --- /dev/null +++ b/src/Helpers/EnvironmentHelper.php @@ -0,0 +1,11 @@ +<?php + +namespace SimpleSAML\Module\accounting\Helpers; + +class EnvironmentHelper +{ + public function isCli(): bool + { + return http_response_code() === false; + } +} diff --git a/src/Helpers/FilesystemHelper.php b/src/Helpers/FilesystemHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..1ee692215bc2ce4a7f4f3ecb7a03905e870dd4f8 --- /dev/null +++ b/src/Helpers/FilesystemHelper.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Exceptions\InvalidValueException; + +class FilesystemHelper +{ + public function getRealPath(string $path): string + { + $realpath = realpath($path); + + if ($realpath === false || ! (is_dir($realpath) || is_file($realpath))) { + throw new InvalidValueException(sprintf('Given path can not be translated to real path (%s).', $path)); + } + + return $realpath; + } +} diff --git a/src/Helpers/HashHelper.php b/src/Helpers/HashHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..ea38562f48cc284eed171c01817d0d30893b69da --- /dev/null +++ b/src/Helpers/HashHelper.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Helpers; + +class HashHelper +{ + protected ArrayHelper $arrayHelper; + + public function __construct(ArrayHelper $arrayHelper) + { + $this->arrayHelper = $arrayHelper; + } + + public function getSha256(string $data): string + { + return hash('sha256', $data); + } + + public function getSha256ForArray(array $array): string + { + $this->arrayHelper->recursivelySortByKey($array); + return $this->getSha256(serialize($array)); + } +} diff --git a/src/Helpers/InstanceBuilderUsingModuleConfigurationHelper.php b/src/Helpers/InstanceBuilderUsingModuleConfigurationHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..fed30792f71320136230bf25f825919a4c594d75 --- /dev/null +++ b/src/Helpers/InstanceBuilderUsingModuleConfigurationHelper.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Helpers; + +use Psr\Log\LoggerInterface; +use ReflectionMethod; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +class InstanceBuilderUsingModuleConfigurationHelper +{ + /** + * @param class-string $class + * @param ModuleConfiguration $moduleConfiguration + * @param LoggerInterface $logger + * @param array $additionalArguments + * @param string $method + * @return BuildableUsingModuleConfigurationInterface + * @throws Exception + */ + public function build( + string $class, + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + array $additionalArguments = [], + string $method = BuildableUsingModuleConfigurationInterface::BUILD_METHOD + ): BuildableUsingModuleConfigurationInterface { + try { + $this->validateClass($class); + + $allArguments = array_merge([$moduleConfiguration, $logger], $additionalArguments); + + $reflectionMethod = new ReflectionMethod($class, $method); + /** @var BuildableUsingModuleConfigurationInterface $instance */ + $instance = $reflectionMethod->invoke(null, ...$allArguments); + } catch (\Throwable $exception) { + $message = \sprintf( + 'Error building instance using module configuration. Error was: %s.', + $exception->getMessage() + ); + throw new Exception($message, (int)$exception->getCode(), $exception); + } + + return $instance; + } + + protected function validateClass(string $class): void + { + if (!is_subclass_of($class, BuildableUsingModuleConfigurationInterface::class)) { + $message = sprintf( + 'Class \'%s\' does not implement interface \'%s\'.', + $class, + BuildableUsingModuleConfigurationInterface::class + ); + throw new UnexpectedValueException($message); + } + } +} diff --git a/src/Helpers/ModuleRoutesHelper.php b/src/Helpers/ModuleRoutesHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..ed41487ec70ff43ab744f97e3df745c25b7253ef --- /dev/null +++ b/src/Helpers/ModuleRoutesHelper.php @@ -0,0 +1,31 @@ +<?php + +namespace SimpleSAML\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Utils\HTTP; + +class ModuleRoutesHelper +{ + public const PATH_ADMIN_CONFIGURATION_STATUS = 'admin/configuration/status'; + + public const PATH_USER_PERSONAL_DATA = 'user/personal-data'; + + protected HTTP $sspHttpUtils; + + public function __construct(HTTP $sspHttpUtils = null) + { + $this->sspHttpUtils = $sspHttpUtils ?? new HTTP(); + } + + public function getUrl(string $path, array $parameters = []): string + { + $url = $this->sspHttpUtils->getBaseURL() . 'module.php/' . ModuleConfiguration::MODULE_NAME . '/' . $path; + + if (!empty($parameters)) { + $url = $this->sspHttpUtils->addURLParameters($url, $parameters); + } + + return $url; + } +} diff --git a/src/Helpers/NetworkHelper.php b/src/Helpers/NetworkHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..d7159a40260aa8386a9b69cbed85635a3ae8ddaf --- /dev/null +++ b/src/Helpers/NetworkHelper.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Helpers; + +class NetworkHelper +{ + public function resolveClientIpAddress(string $clientIpAddress = null): ?string + { + /** @var string|null $clientIpAddress */ + $clientIpAddress = $clientIpAddress ?? + $_SERVER['HTTP_CLIENT_IP'] ?? + $_SERVER['HTTP_X_FORWARDED_FOR'] ?? + $_SERVER['REMOTE_ADDR'] ?? + null; + + if (!is_string($clientIpAddress)) { + return null; + } + + $ips = explode(',', $clientIpAddress); + + $ip = mb_substr(trim(array_pop($ips)), 0, 45); + + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + + return null; + } +} diff --git a/src/Helpers/RandomHelper.php b/src/Helpers/RandomHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..3a74a7fe318a6705c8835bc10a2ae02c72e90d63 --- /dev/null +++ b/src/Helpers/RandomHelper.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Helpers; + +class RandomHelper +{ + public function getRandomInt(int $minimum = PHP_INT_MIN, int $maximum = PHP_INT_MAX): int + { + try { + return random_int($minimum, $maximum); + // @codeCoverageIgnoreStart + } catch (\Throwable $exception) { + return mt_rand($minimum, $maximum); + // @codeCoverageIgnoreEnd + } + } +} diff --git a/src/Http/Controllers/Admin/Configuration.php b/src/Http/Controllers/Admin/Configuration.php new file mode 100644 index 0000000000000000000000000000000000000000..909518ce4e4dbdf56fc72f881c67870c7546c668 --- /dev/null +++ b/src/Http/Controllers/Admin/Configuration.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Http\Controllers\Admin; + +use Exception; +use Psr\Log\LoggerInterface; +use SimpleSAML\Configuration as SspConfiguration; +use SimpleSAML\Module\accounting\Helpers\ModuleRoutesHelper; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder; +use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder; +use SimpleSAML\Session; +use SimpleSAML\Utils; +use SimpleSAML\Utils\Auth; +use SimpleSAML\XHTML\Template; +use Symfony\Component\HttpFoundation\Request; +use Throwable; + +class Configuration +{ + protected SspConfiguration $sspConfiguration; + protected Session $session; + protected LoggerInterface $logger; + protected Utils\Auth $sspAuthUtils; + protected HelpersManager $helpersManager; + + public function __construct( + SspConfiguration $sspConfiguration, + Session $session, + LoggerInterface $logger, + HelpersManager $helpersManager, + Utils\Auth $sspAuthUtils = null + ) { + $this->sspConfiguration = $sspConfiguration; + $this->session = $session; + $this->logger = $logger; + $this->helpersManager = $helpersManager; + $this->sspAuthUtils = $sspAuthUtils ?? new Utils\Auth(); + + $this->sspAuthUtils->requireAdmin(); + } + + /** + * @param Request $request + * @return Template + * @throws Exception + */ + public function status(Request $request): Template + { + // Instantiate ModuleConfiguration here (instead in constructor) so we can check for validation errors. + $moduleConfiguration = null; + $configurationValidationErrors = null; + $jobsStore = null; + $defaultDataTrackerAndProvider = null; + $additionalTrackers = []; + $setupNeeded = false; + $runSetup = $request->query->has('runSetup'); + + try { + $moduleConfiguration = new ModuleConfiguration(); + + $defaultDataTrackerAndProvider = + (new AuthenticationDataTrackerBuilder($moduleConfiguration, $this->logger, $this->helpersManager)) + ->build($moduleConfiguration->getDefaultDataTrackerAndProviderClass()); + + if ($defaultDataTrackerAndProvider->needsSetup()) { + if ($runSetup) { + $defaultDataTrackerAndProvider->runSetup(); + } else { + $setupNeeded = true; + } + } + + if ( + $moduleConfiguration->getAccountingProcessingType() === + ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS + ) { + $jobsStore = (new JobsStoreBuilder($moduleConfiguration, $this->logger, $this->helpersManager)) + ->build($moduleConfiguration->getJobsStoreClass()); + if ($jobsStore->needsSetup()) { + if ($runSetup) { + $jobsStore->runSetup(); + } else { + $setupNeeded = true; + } + } + } + + foreach ($moduleConfiguration->getAdditionalTrackers() as $trackerClass) { + $additionalTrackerInstance = + (new AuthenticationDataTrackerBuilder($moduleConfiguration, $this->logger, $this->helpersManager)) + ->build($trackerClass); + + if ($additionalTrackerInstance->needsSetup()) { + if ($runSetup) { + $additionalTrackerInstance->runSetup(); + } else { + $setupNeeded = true; + } + } + $additionalTrackers[$trackerClass] = $additionalTrackerInstance; + } + } catch (Throwable $exception) { + $configurationValidationErrors = $exception->getMessage(); + } + + $templateData = [ + 'moduleConfiguration' => $moduleConfiguration, + 'configurationValidationErrors' => $configurationValidationErrors, + 'jobsStore' => $jobsStore, + 'defaultDataTrackerAndProvider' => $defaultDataTrackerAndProvider, + 'additionalTrackers' => $additionalTrackers, + 'setupNeeded' => $setupNeeded, + 'profilePageUri' => $this->helpersManager->getModuleRoutesHelper() + ->getUrl(ModuleRoutesHelper::PATH_USER_PERSONAL_DATA), + ]; + + $template = new Template($this->sspConfiguration, 'accounting:admin/configuration/status.twig'); + + $template->data = $templateData; + return $template; + } +} diff --git a/src/Http/Controllers/Test.php b/src/Http/Controllers/Test.php new file mode 100644 index 0000000000000000000000000000000000000000..e7b4446d6942869d0d30d92e60655bef7c047ae5 --- /dev/null +++ b/src/Http/Controllers/Test.php @@ -0,0 +1,75 @@ +<?php +// phpcs:ignoreFile + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Http\Controllers; + +use Exception; +use Psr\Log\LoggerInterface; +use SimpleSAML\Configuration as SspConfiguration; +use SimpleSAML\Locale\Translate; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; +use SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker; +use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder; +use SimpleSAML\Session; +use SimpleSAML\XHTML\Template; +use Symfony\Component\HttpFoundation\Request; + +/** + * TODO mivanci delete this file before release + * @psalm-suppress all + */ +class Test +{ + protected SspConfiguration $sspConfiguration; + protected Session $session; + protected ModuleConfiguration $moduleConfiguration; + protected LoggerInterface $logger; + protected HelpersManager $helpersManager; + + /** + * @param SspConfiguration $sspConfiguration + * @param Session $session The current user session. + * @param ModuleConfiguration $moduleConfiguration + * @param LoggerInterface $logger + */ + public function __construct( + SspConfiguration $sspConfiguration, + Session $session, + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + HelpersManager $helpersManager + ) { + $this->sspConfiguration = $sspConfiguration; + $this->session = $session; + $this->moduleConfiguration = $moduleConfiguration; + $this->logger = $logger; + $this->helpersManager = $helpersManager; + } + + /** + * @param Request $request + * @return Template + * @throws Exception + */ + public function test(Request $request): Template + { + $template = new Template($this->sspConfiguration, 'accounting:test.twig'); + + $retentionPolicy = new \DateInterval('P4D'); + + (new AuthenticationDataTrackerBuilder($this->moduleConfiguration, $this->logger, $this->helpersManager)) + ->build($this->moduleConfiguration->getDefaultDataTrackerAndProviderClass()) + ->enforceDataRetentionPolicy($retentionPolicy); + + die('end'); + + return $template; + } +} diff --git a/src/Http/Controllers/User/Profile.php b/src/Http/Controllers/User/Profile.php new file mode 100644 index 0000000000000000000000000000000000000000..aba52d9356602ed28a3ed1e095557abb0f3c138d --- /dev/null +++ b/src/Http/Controllers/User/Profile.php @@ -0,0 +1,191 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Http\Controllers\User; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Auth\Simple; +use SimpleSAML\Configuration as SspConfiguration; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\Helpers\AttributesHelper; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\ModuleConfiguration\ConnectionType; +use SimpleSAML\Module\accounting\Providers\Builders\AuthenticationDataProviderBuilder; +use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Session; +use SimpleSAML\XHTML\Template; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +class Profile +{ + protected ModuleConfiguration $moduleConfiguration; + protected SspConfiguration $sspConfiguration; + protected Session $session; + protected LoggerInterface $logger; + protected string $defaultAuthenticationSource; + protected Simple $authSimple; + protected AuthenticationDataProviderBuilder $authenticationDataProviderBuilder; + protected HelpersManager $helpersManager; + + /** + * @param ModuleConfiguration $moduleConfiguration + * @param SspConfiguration $sspConfiguration + * @param Session $session The current user session. + * @param LoggerInterface $logger + * @param Simple|null $authSimple + * @param AuthenticationDataProviderBuilder|null $authenticationDataProviderBuilder + * @param HelpersManager|null $helpersManager + */ + public function __construct( + ModuleConfiguration $moduleConfiguration, + SspConfiguration $sspConfiguration, + Session $session, + LoggerInterface $logger, + Simple $authSimple = null, + AuthenticationDataProviderBuilder $authenticationDataProviderBuilder = null, + HelpersManager $helpersManager = null + ) { + $this->moduleConfiguration = $moduleConfiguration; + $this->sspConfiguration = $sspConfiguration; + $this->session = $session; + $this->logger = $logger; + + $this->defaultAuthenticationSource = $moduleConfiguration->getDefaultAuthenticationSource(); + $this->authSimple = $authSimple ?? new Simple($this->defaultAuthenticationSource, $sspConfiguration, $session); + + $this->helpersManager = $helpersManager ?? new HelpersManager(); + + $this->authenticationDataProviderBuilder = $authenticationDataProviderBuilder ?? + new AuthenticationDataProviderBuilder($this->moduleConfiguration, $this->logger, $this->helpersManager); + + // Make sure the end user is authenticated. + $this->authSimple->requireAuth(); + } + + public function personalData(Request $request): Response + { + $normalizedAttributes = []; + + $toNameAttributeMap = $this->prepareToNameAttributeMap(); + + /** + * @var string $name + * @var string[] $value + */ + foreach ($this->authSimple->getAttributes() as $name => $value) { + // Convert attribute names to user-friendly names. + if (array_key_exists($name, $toNameAttributeMap)) { + $name = (string)$toNameAttributeMap[$name]; + } + $normalizedAttributes[$name] = implode('; ', $value); + } + + $template = $this->resolveTemplate('accounting:user/personal-data.twig'); + $template->data = compact('normalizedAttributes'); + + return $template; + } + + public function connectedOrganizations(Request $request): Template + { + $userIdentifier = $this->resolveUserIdentifier(); + + $authenticationDataProvider = $this->resolveAuthenticationDataProvider(); + + $this->removeDebugDisplayLimits(); + $connectedServiceProviderBag = $authenticationDataProvider->getConnectedServiceProviders($userIdentifier); + + $template = $this->resolveTemplate('accounting:user/connected-organizations.twig'); + $template->data = compact('connectedServiceProviderBag'); + + return $template; + } + + public function activity(Request $request): Template + { + $userIdentifier = $this->resolveUserIdentifier(); + + $authenticationDataProvider = $this->resolveAuthenticationDataProvider(); + + $page = ($page = (int)$request->query->get('page', 1)) > 0 ? $page : 1; + + $maxResults = 10; + $firstResult = ($page - 1) * $maxResults; + + $this->removeDebugDisplayLimits(); + $activityBag = $authenticationDataProvider->getActivity($userIdentifier, $maxResults, $firstResult); + + $template = $this->resolveTemplate('accounting:user/activity.twig'); + $template->data = compact('activityBag', 'page', 'maxResults'); + + return $template; + } + + protected function resolveUserIdentifier(): string + { + $attributes = $this->authSimple->getAttributes(); + $idAttributeName = $this->moduleConfiguration->getUserIdAttributeName(); + + if (empty($attributes[$idAttributeName]) || !is_array($attributes[$idAttributeName])) { + $message = sprintf('No identifier %s present in user attributes.', $idAttributeName); + throw new Exception($message); + } + + return (string)reset($attributes[$idAttributeName]); + } + + /** + * @throws Exception + */ + protected function resolveAuthenticationDataProvider(): AuthenticationDataProviderInterface + { + return $this->authenticationDataProviderBuilder + ->build( + $this->moduleConfiguration->getDefaultDataTrackerAndProviderClass(), + ConnectionType::SLAVE + ); + } + + public function logout(): Response + { + return new RunnableResponse([$this->authSimple, 'logout'], [$this->getLogoutUrl()]); + } + + protected function getLogoutUrl(): string + { + return $this->sspConfiguration->getBasePath() . 'logout.php'; + } + + /** + * Load all attribute map files which translate attribute names to user-friendly name format. + */ + protected function prepareToNameAttributeMap(): array + { + return $this->helpersManager->getAttributesHelper()->getMergedAttributeMapForFiles( + $this->sspConfiguration->getBaseDir(), + AttributesHelper::MAP_FILES_TO_NAME + ); + } + + /** TODO mivanci remove after debugging */ + protected function removeDebugDisplayLimits(): void + { + ini_set('xdebug.var_display_max_depth', '-1'); + ini_set('xdebug.var_display_max_children', '-1'); + ini_set('xdebug.var_display_max_data', '-1'); + } + + protected function resolveTemplate(string $template): Template + { + $templateInstance = new Template($this->sspConfiguration, $template); + + $templateInstance->getLocalization()->addModuleDomain(ModuleConfiguration::MODULE_NAME); + $templateInstance->getLocalization()->addAttributeDomains(); + + return $templateInstance; + } +} diff --git a/src/Interfaces/BuildableUsingModuleConfigurationInterface.php b/src/Interfaces/BuildableUsingModuleConfigurationInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..c1a4aedc646a07c1abbd697f4b25e493c8480647 --- /dev/null +++ b/src/Interfaces/BuildableUsingModuleConfigurationInterface.php @@ -0,0 +1,13 @@ +<?php + +namespace SimpleSAML\Module\accounting\Interfaces; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +interface BuildableUsingModuleConfigurationInterface +{ + public const BUILD_METHOD = 'build'; + + public static function build(ModuleConfiguration $moduleConfiguration, LoggerInterface $logger): self; +} diff --git a/src/Interfaces/SetupableInterface.php b/src/Interfaces/SetupableInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..da036e145c3d1ea6d3af16f6d92aa60ad77cd70b --- /dev/null +++ b/src/Interfaces/SetupableInterface.php @@ -0,0 +1,9 @@ +<?php + +namespace SimpleSAML\Module\accounting\Interfaces; + +interface SetupableInterface +{ + public function needsSetup(): bool; + public function runSetup(): void; +} diff --git a/src/ModuleConfiguration.php b/src/ModuleConfiguration.php new file mode 100644 index 0000000000000000000000000000000000000000..fe83f63151d45129b09dde07630cb4e0df2ca903 --- /dev/null +++ b/src/ModuleConfiguration.php @@ -0,0 +1,488 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting; + +use DateInterval; +use Exception; +use SimpleSAML\Configuration; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\ModuleConfiguration\AccountingProcessingType; +use SimpleSAML\Module\accounting\ModuleConfiguration\ConnectionType; +use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface; +use SimpleSAML\Module\accounting\Stores\Interfaces\JobsStoreInterface; +use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface; +use Throwable; + +class ModuleConfiguration +{ + public const MODULE_NAME = 'accounting'; + + /** + * Default file name for module configuration. Can be overridden, for example, for testing purposes. + */ + public const FILE_NAME = 'module_accounting.php'; + + public const OPTION_USER_ID_ATTRIBUTE_NAME = 'user_id_attribute_name'; + public const OPTION_DEFAULT_AUTHENTICATION_SOURCE = 'default_authentication_source'; + public const OPTION_ACCOUNTING_PROCESSING_TYPE = 'accounting_processing_type'; + public const OPTION_JOBS_STORE = 'jobs_store'; + public const OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER = 'default_data_tracker_and_provider'; + public const OPTION_ADDITIONAL_TRACKERS = 'additional_trackers'; + public const OPTION_CONNECTIONS_AND_PARAMETERS = 'connections_and_parameters'; + public const OPTION_CLASS_TO_CONNECTION_MAP = 'class_to_connection_map'; + public const OPTION_CRON_TAG_FOR_JOB_RUNNER = 'cron_tag_for_job_runner'; + public const OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME = 'job_runner_maximum_execution_time'; + public const OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED = + 'job_runner_should_pause_after_number_of_jobs_processed'; + public const OPTION_TRACKER_DATA_RETENTION_POLICY = 'tracker_data_retention_policy'; + public const OPTION_CRON_TAG_FOR_TRACKER_DATA_RETENTION_POLICY = 'cron_tag_for_tracker_data_retention_policy'; + + /** + * Contains configuration from module configuration file. + */ + protected Configuration $configuration; + + /** + * @throws Exception + */ + public function __construct(string $fileName = null, array $overrides = []) + { + $fileName = $fileName ?? self::FILE_NAME; + + $fullConfigArray = array_merge(Configuration::getConfig($fileName)->toArray(), $overrides); + + $this->configuration = Configuration::loadFromArray($fullConfigArray); + + $this->validate(); + } + + public function getAccountingProcessingType(): string + { + return $this->getConfiguration()->getString(self::OPTION_ACCOUNTING_PROCESSING_TYPE); + } + + public function getCronTagForJobRunner(): string + { + return $this->getConfiguration()->getString(self::OPTION_CRON_TAG_FOR_JOB_RUNNER); + } + + /** + * Get underlying SimpleSAMLphp Configuration instance. + * + * @return Configuration + */ + public function getConfiguration(): Configuration + { + return $this->configuration; + } + + public function getJobsStoreClass(): string + { + return $this->getConfiguration()->getString(self::OPTION_JOBS_STORE); + } + + public function getJobRunnerMaximumExecutionTime(): ?DateInterval + { + $value = $this->get(self::OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME); + + if (is_null($value)) { + return null; + } + + if (! is_string($value)) { + $message = sprintf('Job runner maximum activity must be defined either as null, or DateInterval' . + 'duration (string).'); + throw new InvalidConfigurationException($message); + } + + try { + return new DateInterval($value); + } catch (Throwable $exception) { + $message = sprintf('Can not create DateInterval instance using value %s as parameter.', $value); + throw new InvalidConfigurationException($message); + } + } + + public function getJobRunnerShouldPauseAfterNumberOfJobsProcessed(): ?int + { + $value = $this->get(self::OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED); + + if (is_null($value)) { + return null; + } + + if (! is_int($value)) { + $message = sprintf( + 'Option \'%s\' must be defined either as null, or positive integer.', + self::OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED + ); + throw new InvalidConfigurationException($message); + } + + if ($value < 1) { + $message = sprintf( + 'Option \'%s\' must positive integer.', + self::OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED + ); + throw new InvalidConfigurationException($message); + } + + return $value; + } + + public function getDefaultDataTrackerAndProviderClass(): string + { + return $this->getConfiguration()->getString(self::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER); + } + + public function getConnectionsAndParameters(): array + { + return $this->getConfiguration()->getArray(self::OPTION_CONNECTIONS_AND_PARAMETERS); + } + + /** + * @return string[] + * @psalm-suppress MixedReturnTypeCoercion We specifically check if valid class string are provided. + */ + public function getAdditionalTrackers(): array + { + return $this->getConfiguration()->getArray(self::OPTION_ADDITIONAL_TRACKERS); + } + + public function getClassToConnectionsMap(): array + { + return $this->getConfiguration()->getArray(self::OPTION_CLASS_TO_CONNECTION_MAP); + } + + /** + * Get configuration option from module configuration file. + * + * @param string $option + * @return mixed + */ + public function get(string $option) + { + if (!$this->configuration->hasValue($option)) { + throw new InvalidConfigurationException( + sprintf('Configuration option does not exist (%s).', $option) + ); + } + + return $this->configuration->getValue($option); + } + + public function getUserIdAttributeName(): string + { + return $this->getConfiguration()->getString(self::OPTION_USER_ID_ATTRIBUTE_NAME); + } + + public function getDefaultAuthenticationSource(): string + { + return $this->getConfiguration()->getString(self::OPTION_DEFAULT_AUTHENTICATION_SOURCE); + } + + public function getClassConnectionKey(string $class, string $connectionType = ConnectionType::MASTER): string + { + $this->validateConnectionType($connectionType); + + $connections = $this->getClassToConnectionsMap(); + + if (!isset($connections[$class])) { + throw new InvalidConfigurationException(sprintf('Connection for class \'%s\' not set.', $class)); + } + + $connectionValue = $connections[$class]; + + // If the key is defined directly, return that. + if (is_string($connectionValue)) { + return $connectionValue; + } + + if (!is_array($connectionValue)) { + throw new InvalidConfigurationException( + sprintf('Connection for class \'%s\' is not defined as string nor as array.', $class) + ); + } + + if (!isset($connectionValue[ConnectionType::MASTER])) { + $message = sprintf( + 'Connection for class \'%s\' is defined as array, however no master connection key is set.', + $class + ); + throw new InvalidConfigurationException($message); + } + + // By default, use master connection key. + $connectionKey = (string)$connectionValue[ConnectionType::MASTER]; + + if ($connectionType === ConnectionType::MASTER || (! isset($connectionValue[ConnectionType::SLAVE]))) { + return $connectionKey; + } + + if (is_array($connectionValue[ConnectionType::SLAVE])) { + // Return random slave connection key. + $slaveConnections = $connectionValue[ConnectionType::SLAVE]; + $connectionKey = (string)$slaveConnections[array_rand($slaveConnections)]; + } + + return $connectionKey; + } + + /** + * @throws InvalidConfigurationException + */ + public function getClassConnectionParameters(string $class, string $connectionType = ConnectionType::MASTER): array + { + return $this->getConnectionParameters($this->getClassConnectionKey($class, $connectionType)); + } + + /** + * @throws InvalidConfigurationException + */ + public function getConnectionParameters(string $connectionKey): array + { + $connections = $this->getConnectionsAndParameters(); + + if (!isset($connections[$connectionKey]) || !is_array($connections[$connectionKey])) { + throw new InvalidConfigurationException( + sprintf('Connection parameters not set for key \'%s\'.', $connectionKey) + ); + } + + return $connections[$connectionKey]; + } + + public function getModuleSourceDirectory(): string + { + return __DIR__; + } + + public function getModuleRootDirectory(): string + { + return dirname(__DIR__); + } + + /** + * @throws InvalidConfigurationException + */ + protected function validate(): void + { + $errors = []; + + try { + $this->validateAccountingProcessingType(); + } catch (Throwable $exception) { + $errors[] = $exception->getMessage(); + } + + // If accounting processing type is async, validate jobs store class and connection. + if ($this->getAccountingProcessingType() === AccountingProcessingType::VALUE_ASYNCHRONOUS) { + try { + $this->validateJobsStoreClass(); + $this->validateCronTagForJobRunner(); + } catch (Throwable $exception) { + $errors[] = $exception->getMessage(); + } + } + + try { + $this->validateDefaultDataTrackerAndProvider(); + } catch (Throwable $exception) { + $errors[] = $exception->getMessage(); + } + + try { + $this->validateAdditionalTrackers(); + } catch (Throwable $exception) { + $errors[] = $exception->getMessage(); + } + + try { + $this->validateClassToConnectionMap(); + } catch (Throwable $exception) { + $errors[] = $exception->getMessage(); + } + + try { + $this->validateTrackerDataRetentionPolicy(); + } catch (Throwable $exception) { + $errors[] = $exception->getMessage(); + } + + + if (!empty($errors)) { + $message = sprintf('Module configuration validation failed with errors: %s', implode(' ', $errors)); + throw new InvalidConfigurationException($message); + } + } + + /** + * @throws InvalidConfigurationException + */ + protected function validateDefaultDataTrackerAndProvider(): void + { + $errors = []; + + // Default data tracker and provider must implement proper interfaces. + $defaultDataTrackerAndProviderClass = $this->getDefaultDataTrackerAndProviderClass(); + + if (!is_subclass_of($defaultDataTrackerAndProviderClass, AuthenticationDataTrackerInterface::class)) { + $errors[] = sprintf( + 'Default authentication data tracker and provider class \'%s\' does not implement interface \'%s\'.', + $defaultDataTrackerAndProviderClass, + AuthenticationDataTrackerInterface::class + ); + } + + if (!is_subclass_of($defaultDataTrackerAndProviderClass, AuthenticationDataProviderInterface::class)) { + $errors[] = sprintf( + 'Default authentication data tracker and provider class \'%s\' does not implement interface \'%s\'.', + $defaultDataTrackerAndProviderClass, + AuthenticationDataProviderInterface::class + ); + } + + if (!empty($errors)) { + throw new InvalidConfigurationException(implode(' ', $errors)); + } + } + + /** + * @throws InvalidConfigurationException + */ + protected function validateAdditionalTrackers(): void + { + $errors = []; + + // Validate additional trackers + foreach ($this->getAdditionalTrackers() as $trackerClass) { + /** @psalm-suppress DocblockTypeContradiction */ + if (!is_string($trackerClass)) { + $errors[] = 'Additional trackers array must contain class strings only.'; + } elseif (!is_subclass_of($trackerClass, AuthenticationDataTrackerInterface::class)) { + $errors[] = sprintf( + 'Tracker class \'%s\' does not implement interface \'%s\'.', + $trackerClass, + AuthenticationDataTrackerInterface::class + ); + } + } + + if (!empty($errors)) { + throw new InvalidConfigurationException(implode(' ', $errors)); + } + } + + /** + * @throws InvalidConfigurationException + */ + protected function validateConnectionType(string $connectionType): void + { + if (!in_array($connectionType, ConnectionType::VALID_OPTIONS)) { + $message = sprintf( + 'Connection type \'%s\' is not valid. Possible values are: %s.', + $connectionType, + implode(', ', ConnectionType::VALID_OPTIONS) + ); + throw new InvalidConfigurationException($message); + } + } + + /** + * @throws InvalidConfigurationException + */ + protected function validateAccountingProcessingType(): void + { + // Only defined accounting processing types are allowed. + if (!in_array($this->getAccountingProcessingType(), AccountingProcessingType::VALID_OPTIONS)) { + $message = sprintf( + 'Accounting processing type is not valid; possible values are: %s.', + implode(', ', AccountingProcessingType::VALID_OPTIONS) + ); + + throw new InvalidConfigurationException($message); + } + } + + /** + * @throws InvalidConfigurationException + */ + protected function validateJobsStoreClass(): void + { + // Jobs store class must implement JobsStoreInterface + $jobsStore = $this->getJobsStoreClass(); + if (!class_exists($jobsStore) || !is_subclass_of($jobsStore, JobsStoreInterface::class)) { + $message = sprintf( + 'Provided jobs store class \'%s\' does not implement interface \'%s\'.', + $jobsStore, + JobsStoreInterface::class + ); + + throw new InvalidConfigurationException($message); + } + } + + /** + * @throws InvalidConfigurationException + */ + protected function validateClassToConnectionMap(): void + { + $errors = []; + + $connectionsAndParameters = $this->getConnectionsAndParameters(); + // Each defined class should have defined connection parameters. + $classToConnectionMap = array_keys($this->getClassToConnectionsMap()); + /** @var string $class */ + foreach ($classToConnectionMap as $class) { + $connectionKey = $this->getClassConnectionKey($class); + if (! array_key_exists($connectionKey, $connectionsAndParameters)) { + $errors[] = sprintf( + 'Class \'%s\' has connection key \'%s\' set, however parameters for that key are not set.', + $class, + $connectionKey + ); + } + } + + if (!empty($errors)) { + throw new InvalidConfigurationException(implode(' ', $errors)); + } + } + + protected function validateCronTagForJobRunner(): void + { + $this->getCronTagForJobRunner(); + } + + protected function validateTrackerDataRetentionPolicy(): void + { + if ($this->getTrackerDataRetentionPolicy() !== null) { + $this->getCronTagForTrackerDataRetentionPolicy(); + } + } + + public function getTrackerDataRetentionPolicy(): ?DateInterval + { + /** @var string|null $value */ + $value = $this->getConfiguration() + ->getOptionalString(self::OPTION_TRACKER_DATA_RETENTION_POLICY, null); + + if (is_null($value)) { + return null; + } + + try { + return new DateInterval($value); + } catch (Throwable $exception) { + $message = sprintf('Can not create DateInterval instance using value %s as parameter.', $value); + throw new InvalidConfigurationException($message); + } + } + + public function getCronTagForTrackerDataRetentionPolicy(): string + { + return $this->getConfiguration()->getString(self::OPTION_CRON_TAG_FOR_TRACKER_DATA_RETENTION_POLICY); + } +} diff --git a/src/ModuleConfiguration/AccountingProcessingType.php b/src/ModuleConfiguration/AccountingProcessingType.php new file mode 100644 index 0000000000000000000000000000000000000000..fef0718ff35309d446ce2120ccaa9655840ef3ca --- /dev/null +++ b/src/ModuleConfiguration/AccountingProcessingType.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\ModuleConfiguration; + +final class AccountingProcessingType +{ + public const VALUE_SYNCHRONOUS = 'synchronous'; + public const VALUE_ASYNCHRONOUS = 'asynchronous'; + + public const VALID_OPTIONS = [ + self::VALUE_SYNCHRONOUS, + self::VALUE_ASYNCHRONOUS, + ]; +} diff --git a/src/ModuleConfiguration/ConnectionType.php b/src/ModuleConfiguration/ConnectionType.php new file mode 100644 index 0000000000000000000000000000000000000000..0ad53b8acb85e98bd4cae27046edc6a12b73c19a --- /dev/null +++ b/src/ModuleConfiguration/ConnectionType.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\ModuleConfiguration; + +final class ConnectionType +{ + public const MASTER = 'master'; + public const SLAVE = 'slave'; + + public const VALID_OPTIONS = [ + self::MASTER, + self::SLAVE, + ]; +} diff --git a/src/Providers/Builders/AuthenticationDataProviderBuilder.php b/src/Providers/Builders/AuthenticationDataProviderBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..b064f1c2cd8efd288dfbd7643d2e75b083f951e7 --- /dev/null +++ b/src/Providers/Builders/AuthenticationDataProviderBuilder.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Providers\Builders; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use Throwable; + +class AuthenticationDataProviderBuilder +{ + protected ModuleConfiguration $moduleConfiguration; + protected LoggerInterface $logger; + protected HelpersManager $helpersManager; + + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + HelpersManager $helpersManager + ) { + $this->moduleConfiguration = $moduleConfiguration; + $this->logger = $logger; + $this->helpersManager = $helpersManager; + } + + /** + * @throws Exception + */ + public function build( + string $class, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER + ): AuthenticationDataProviderInterface { + try { + // Make sure that the class implements proper interface + if (!is_subclass_of($class, AuthenticationDataProviderInterface::class)) { + $message = sprintf( + 'Class %s does not implement interface %s.', + $class, + AuthenticationDataProviderInterface::class + ); + throw new UnexpectedValueException($message); + } + + // Build... + /** @var AuthenticationDataProviderInterface $store */ + $store = $this->helpersManager->getInstanceBuilderUsingModuleConfigurationHelper()->build( + $class, + $this->moduleConfiguration, + $this->logger, + [$connectionType] + ); + } catch (Throwable $exception) { + $message = sprintf('Error building instance for class %s. Error was: %s', $class, $exception->getMessage()); + throw new Exception($message, (int)$exception->getCode(), $exception); + } + + return $store; + } +} diff --git a/src/Providers/Interfaces/AuthenticationDataProviderInterface.php b/src/Providers/Interfaces/AuthenticationDataProviderInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..8c60297dcefb758df57af9fae386a56963cf58ef --- /dev/null +++ b/src/Providers/Interfaces/AuthenticationDataProviderInterface.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Providers\Interfaces; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Activity; +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; +use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +interface AuthenticationDataProviderInterface extends BuildableUsingModuleConfigurationInterface +{ + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER + ): self; + + public function getConnectedServiceProviders(string $userIdentifier): ConnectedServiceProvider\Bag; + + public function getActivity(string $userIdentifier, int $maxResults, int $firstResult): Activity\Bag; +} diff --git a/src/Services/HelpersManager.php b/src/Services/HelpersManager.php new file mode 100644 index 0000000000000000000000000000000000000000..e66518fa691269da596f533a7451013f1878c604 --- /dev/null +++ b/src/Services/HelpersManager.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Services; + +use SimpleSAML\Module\accounting\Helpers\ArrayHelper; +use SimpleSAML\Module\accounting\Helpers\AttributesHelper; +use SimpleSAML\Module\accounting\Helpers\DateTimeHelper; +use SimpleSAML\Module\accounting\Helpers\EnvironmentHelper; +use SimpleSAML\Module\accounting\Helpers\FilesystemHelper; +use SimpleSAML\Module\accounting\Helpers\HashHelper; +use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper; +use SimpleSAML\Module\accounting\Helpers\NetworkHelper; +use SimpleSAML\Module\accounting\Helpers\RandomHelper; +use SimpleSAML\Module\accounting\Helpers\ModuleRoutesHelper; + +class HelpersManager +{ + protected static ?DateTimeHelper $dateTimeHelper; + protected static ?EnvironmentHelper $environmentHelper; + protected static ?RandomHelper $randomHelper; + protected static ?ModuleRoutesHelper $routesHelper; + protected static ?ArrayHelper $arrayHelper; + protected static ?HashHelper $hashHelper; + protected static ?AttributesHelper $attributesHelper; + protected static ?FilesystemHelper $filesystemHelper; + protected static ?InstanceBuilderUsingModuleConfigurationHelper $instanceBuilderHelper; + protected static ?NetworkHelper $networkHelper; + + public function getDateTimeHelper(): DateTimeHelper + { + return self::$dateTimeHelper ??= new DateTimeHelper(); + } + + public function getEnvironmentHelper(): EnvironmentHelper + { + return self::$environmentHelper ??= new EnvironmentHelper(); + } + + public function getRandomHelper(): RandomHelper + { + return self::$randomHelper ??= new RandomHelper(); + } + + public function getModuleRoutesHelper(): ModuleRoutesHelper + { + return self::$routesHelper ??= new ModuleRoutesHelper(); + } + + public function getArrayHelper(): ArrayHelper + { + return self::$arrayHelper ??= new ArrayHelper(); + } + + public function getHashHelper(): HashHelper + { + return self::$hashHelper ??= new HashHelper($this->getArrayHelper()); + } + + public function getAttributesHelper(): AttributesHelper + { + return self::$attributesHelper ??= new AttributesHelper(); + } + + public function getFilesystemHelper(): FilesystemHelper + { + return self::$filesystemHelper ??= new FilesystemHelper(); + } + + public function getInstanceBuilderUsingModuleConfigurationHelper(): InstanceBuilderUsingModuleConfigurationHelper + { + return self::$instanceBuilderHelper ??= new InstanceBuilderUsingModuleConfigurationHelper(); + } + + public function getNetworkHelper(): NetworkHelper + { + return self::$networkHelper ??= new NetworkHelper(); + } +} diff --git a/src/Services/JobRunner.php b/src/Services/JobRunner.php new file mode 100644 index 0000000000000000000000000000000000000000..b08d96f17e4903a5b12b4def57108e3544f956b8 --- /dev/null +++ b/src/Services/JobRunner.php @@ -0,0 +1,599 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Services; + +use Cicnavi\SimpleFileCache\SimpleFileCache; +use Psr\Log\LoggerInterface; +use Psr\SimpleCache\CacheInterface; +use Psr\SimpleCache\InvalidArgumentException; +use SimpleSAML\Configuration as SspConfiguration; +use SimpleSAML\Module\accounting\Entities\Authentication\Event\Job; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\JobRunner\RateLimiter; +use SimpleSAML\Module\accounting\Services\JobRunner\State; +use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder; +use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder; +use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface; + +class JobRunner +{ + protected ModuleConfiguration $moduleConfiguration; + protected SspConfiguration $sspConfiguration; + protected LoggerInterface $logger; + protected AuthenticationDataTrackerBuilder $authenticationDataTrackerBuilder; + protected JobsStoreBuilder $jobsStoreBuilder; + protected CacheInterface $cache; + protected State $state; + + protected const CACHE_NAME = 'accounting-job-runner-cache'; + protected const CACHE_KEY_STATE = 'state'; + + /** + * Interval after which the state will be considered stale. + */ + public const STATE_STALE_THRESHOLD_INTERVAL = 'PT5M'; + + /** + * @var int $jobRunnerId ID of the current job runner instance. + */ + protected int $jobRunnerId; + protected array $trackers; + protected \DateInterval $stateStaleThresholdInterval; + protected RateLimiter $rateLimiter; + protected HelpersManager $helpersManager; + protected ?\DateInterval $maximumExecutionTime; + protected ?int $shouldPauseAfterNumberOfJobsProcessed; + + public function __construct( + ModuleConfiguration $moduleConfiguration, + SspConfiguration $sspConfiguration, + LoggerInterface $logger = null, + HelpersManager $helpersManager = null, + AuthenticationDataTrackerBuilder $authenticationDataTrackerBuilder = null, + JobsStoreBuilder $jobsStoreBuilder = null, + CacheInterface $cache = null, + State $state = null, + RateLimiter $rateLimiter = null + ) { + $this->moduleConfiguration = $moduleConfiguration; + $this->sspConfiguration = $sspConfiguration; + $this->logger = $logger ?? new Logger(); + $this->helpersManager = $helpersManager ?? new HelpersManager(); + + $this->authenticationDataTrackerBuilder = $authenticationDataTrackerBuilder ?? + new AuthenticationDataTrackerBuilder($this->moduleConfiguration, $this->logger, $this->helpersManager); + $this->jobsStoreBuilder = $jobsStoreBuilder ?? + new JobsStoreBuilder($this->moduleConfiguration, $this->logger, $this->helpersManager); + + $this->cache = $cache ?? $this->resolveCache(); + + $this->jobRunnerId = $this->helpersManager->getRandomHelper()->getRandomInt(); + + $this->state = $state ?? new State($this->jobRunnerId); + + $this->trackers = $this->resolveTrackers(); + $this->stateStaleThresholdInterval = new \DateInterval(self::STATE_STALE_THRESHOLD_INTERVAL); + $this->rateLimiter = $rateLimiter ?? new RateLimiter(); + + $this->maximumExecutionTime = $this->resolveMaximumExecutionTime(); + $this->shouldPauseAfterNumberOfJobsProcessed = + $this->moduleConfiguration->getJobRunnerShouldPauseAfterNumberOfJobsProcessed(); + + $this->registerInterruptHandler(); + } + + /** + * @throws Exception|StoreException + */ + public function run(): State + { + try { + $this->validatePreRunState(); + } catch (\Throwable $exception) { + $message = sprintf( + 'Pre-run state validation failed. Clearing cached state and continuing. Error was %s', + $exception->getMessage() + ); + $this->logger->warning($message); + $this->state->addStatusMessage($message); + $this->clearCachedState(); + } + + try { + $this->validateRunConditions(); + } catch (\Throwable $exception) { + $message = sprintf('Run conditions are not met, stopping. Reason was: %s', $exception->getMessage()); + $this->logger->info($message); + $this->state->addStatusMessage($message); + return $this->state; + } + + $this->logger->debug('Run conditions validated.'); + + $this->initializeCachedState(); + + $jobsStore = $this->jobsStoreBuilder->build($this->moduleConfiguration->getJobsStoreClass()); + + $jobsProcessedSincePause = 0; + + // We have a clean state, we can start processing. + while ($this->shouldRun()) { + try { + /** @var ?Job $job */ + $job = $jobsStore->dequeue(Job::class); + + $this->updateCachedState($this->state); + + declare(ticks=1) { + // No new jobs at the moment.... + if ($job === null) { + $this->state->addStatusMessage('No (more) jobs to process.'); + // If in CLI, do the backoff pause, so we can continue working later. + if ($this->isCli()) { + $message = sprintf( + 'Doing a backoff pause for %s seconds.', + $this->rateLimiter->getCurrentBackoffPauseInSeconds() + ); + $this->logger->debug($message); + $this->state->addStatusMessage($message); + $this->rateLimiter->doBackoffPause(); + $jobsProcessedSincePause = 0; + continue; + } else { + // Since this is a web run, we will break immediately, so we can return HTTP response. + break; + } + } + + // We have a job... + $this->rateLimiter->resetBackoffPause(); + } + + /** @var AuthenticationDataTrackerInterface $tracker */ + foreach ($this->trackers as $tracker) { + /** @var Job $job */ + $tracker->process($job->getPayload()); + } + + $this->state->incrementSuccessfulJobsProcessed(); + + /** @var Job $job */ + $successMessage = sprintf( + 'Successfully processed job with ID %s.', + $job->getId() ?? '(N/A)' + ); + $this->logger->debug($successMessage); + $this->state->addStatusMessage($successMessage); + + // If the job runner friendly pausing is enabled, and if the number of jobs processed since the last + // pause is greater than the configured value, do the pause. + if ( + $this->shouldPauseAfterNumberOfJobsProcessed !== null && + $jobsProcessedSincePause > $this->shouldPauseAfterNumberOfJobsProcessed + ) { + $this->rateLimiter->doPause(); + $jobsProcessedSincePause = 0; + } else { + $jobsProcessedSincePause++; + } + } catch (\Throwable $exception) { + $message = sprintf('Error while processing jobs. Error was: %', $exception->getMessage()); + $context = []; + if (isset($job)) { + $context = ['job' => $job]; + $jobsStore->markFailedJob($job); + } + $this->logger->error($message, $context); + $this->state->incrementFailedJobsProcessed(); + $this->state->addStatusMessage($message); + } + } + + $this->clearCachedState(); + + $this->state->setEndedAt(new \DateTimeImmutable()); + return $this->state; + } + + /** + */ + protected function shouldRun(): bool + { + // Enable this code to tick, which will enable it to catch CTRL-C signals and stop gracefully. + declare(ticks=1) { + if ($this->isMaximumExecutionTimeReached()) { + $message = 'Maximum job runner execution time reached.'; + $this->logger->debug($message); + $this->state->addStatusMessage($message); + return false; + } + + if ($this->state->getTotalJobsProcessed() > (PHP_INT_MAX - 1)) { + $message = 'Maximum number of processed jobs reached.'; + $this->logger->debug($message); + $this->state->addStatusMessage($message); + return false; + } + + try { + $this->validateSelfState(); + } catch (\Throwable $exception) { + $message = sprintf( + 'Job runner state is not valid. Message was: %s', + $exception->getMessage() + ); + $this->logger->warning($message); + $this->state->addStatusMessage($message); + return false; + } + } + + return true; + } + + /** + * @throws Exception + */ + protected function initializeCachedState(): void + { + // Make sure that the state does not exist in the cache. + try { + if ($this->getCachedState() !== null) { + throw new UnexpectedValueException('Job runner state already initialized.'); + } + } catch (\Throwable $exception) { + $message = sprintf('Error initializing job runner state. Error was: %s.', $exception->getMessage()); + $this->logger->error($message); + throw new Exception($message, (int)$exception->getCode(), $exception); + } + + $startedAt = new \DateTimeImmutable(); + $this->state->setStartedAt($startedAt); + $this->updateCachedState($this->state, $startedAt); + } + + /** + * @throws Exception + */ + protected function validatePreRunState(): void + { + $cachedState = $this->getCachedState(); + + // Empty state means that no other job runner is active. + if ($cachedState === null) { + return; + } + + if ($cachedState->getJobRunnerId() === $this->jobRunnerId) { + $message = 'Job runner ID in cached state same as new ID.'; + $this->logger->error($message); + throw new Exception($message); + } + + if ($cachedState->isStale($this->stateStaleThresholdInterval)) { + $message = 'Stale state encountered.'; + $this->logger->warning($message); + throw new Exception($message); + } + } + + /** + * @throws Exception + */ + protected function validateSelfState(): void + { + $cachedState = $this->getCachedState(); + + // Validate state before start. + if ($this->state->hasRunStarted() === false) { + if ($cachedState !== null) { + $message = 'Job run has not started, however cached state has already been initialized.'; + throw new Exception($message); + } + } + + // Validate state after start. + if ($this->state->hasRunStarted() === true) { + if ($cachedState === null) { + $message = 'Job run has started, however cached state has not been initialized.'; + throw new Exception($message); + } + + if ($cachedState->getJobRunnerId() !== $this->jobRunnerId) { + $message = 'Current job runner ID differs from the ID in the cached state.'; + throw new Exception($message); + } + + if ($cachedState->isStale($this->stateStaleThresholdInterval)) { + $message = 'Job runner cached state is stale, which means possible job runner process shutdown' . + ' without cached state clearing.'; + throw new Exception($message); + } + + if ($cachedState->getIsGracefulInterruptInitiated()) { + $message = 'Graceful job processing interrupt initiated.'; + throw new Exception($message); + } + } + } + + protected function isAnotherJobRunnerActive(): bool + { + try { + $cachedState = $this->getCachedState(); + + if ($cachedState === null) { + return false; + } + + // There is cached state, which would indicate that a job runner is active. However, make sure that the + // state is not stale (which indicates that the runner was shutdown without state clearing). If stale, + // this means that the job runner is not active. + if ($cachedState->isStale($this->stateStaleThresholdInterval)) { + $this->logger->warning('Stale cache encountered. Assuming no job runner is active.'); + return false; + } + + return $cachedState->getJobRunnerId() !== $this->jobRunnerId; + } catch (\Throwable $exception) { + $message = sprintf( + 'Error checking if another job runner is active. To play safe, we will assume true. ' . + 'Error was: %s', + $exception->getMessage() + ); + $this->logger->error($message); + return true; + } + } + + /** + * @throws Exception + */ + protected function resolveCache(): SimpleFileCache + { + try { + $this->logger->debug('Trying to initialize job runner cache using SSP datadir.'); + $cache = new SimpleFileCache( + self::CACHE_NAME, + $this->sspConfiguration->getPathValue('datadir') + ); + $this->logger->debug('Successfully initialized cache using SSP datadir.'); + return $cache; + } catch (\Throwable $exception) { + $message = sprintf( + 'Error initializing job runner cache using datadir. Error was: %s', + $exception->getMessage() + ); + $this->logger->debug($message); + } + + try { + $this->logger->debug('Trying to initialize job runner cache using SSP tempdir.'); + $cache = new SimpleFileCache( + self::CACHE_NAME, + $this->sspConfiguration->getPathValue('tempdir') + ); + $this->logger->debug('Successfully initialized job runner cache using SSP tempdir.'); + return $cache; + } catch (\Throwable $exception) { + $message = sprintf( + 'Error initializing job runner cache using tempdir. Error was: %s.', + $exception->getMessage() + ); + $this->logger->debug($message); + } + + try { + $this->logger->debug('Trying to initialize job runner cache using system tmp dir.'); + $cache = new SimpleFileCache(self::CACHE_NAME); + $this->logger->debug('Successfully initialized cache using system tmp dir.'); + return $cache; + } catch (\Throwable $exception) { + $message = sprintf( + 'Error initializing job runner cache. Error was: %s.', + $exception->getMessage() + ); + $this->logger->debug($message); + throw new Exception($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws Exception + */ + protected function clearCachedState(): void + { + /** @psalm-suppress InvalidCatch */ + try { + $this->cache->delete(self::CACHE_KEY_STATE); + } catch (\Throwable | InvalidArgumentException $exception) { + $message = sprintf( + 'Error clearing job runner cache. Error was: %s.', + $exception->getMessage() + ); + $this->logger->error($message); + throw new Exception($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws Exception + */ + protected function getCachedState(): ?State + { + /** @psalm-suppress InvalidCatch */ + try { + /** @var ?State $state */ + $state = $this->cache->get(self::CACHE_KEY_STATE); + if ($state instanceof State) { + return $state; + } else { + return null; + } + } catch (\Throwable | InvalidArgumentException $exception) { + $message = sprintf('Error getting job runner state from cache. Error was: %s', $exception->getMessage()); + throw new Exception($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws Exception + */ + protected function updateCachedState(State $state, \DateTimeImmutable $updatedAt = null): void + { + $updatedAt = $updatedAt ?? new \DateTimeImmutable(); + $state->setUpdatedAt($updatedAt); + + /** @psalm-suppress InvalidCatch */ + try { + $this->cache->set(self::CACHE_KEY_STATE, $state); + } catch (\Throwable | InvalidArgumentException $exception) { + $message = sprintf('Error setting job runner state. Error was: %s.', $exception->getMessage()); + $this->logger->error($message); + throw new Exception($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws Exception + */ + protected function validateRunConditions(): void + { + if ( + $this->moduleConfiguration->getAccountingProcessingType() !== + ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS + ) { + $message = 'Job runner called, however accounting mode is not ' . + ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS; + $this->logger->warning($message); + throw new Exception($message); + } + + if ($this->isAnotherJobRunnerActive()) { + $message = 'Another job runner is active.'; + $this->logger->debug($message); + throw new Exception($message); + } + } + + /** + * @throws Exception + */ + protected function resolveTrackers(): array + { + $trackers = []; + + $configuredTrackerClasses = array_merge( + [$this->moduleConfiguration->getDefaultDataTrackerAndProviderClass()], + $this->moduleConfiguration->getAdditionalTrackers() + ); + + foreach ($configuredTrackerClasses as $trackerClass) { + $trackers[$trackerClass] = $this->authenticationDataTrackerBuilder->build($trackerClass); + } + + return $trackers; + } + + protected function isCli(): bool + { + return $this->helpersManager->getEnvironmentHelper()->isCli(); + } + + /** + * Register interrupt handler. This makes it possible to stop job processing gracefully by + * clearing the current state. It relies on pcntl extension, so to use this feature, + * that extension has to be enabled. + * @see https://www.php.net/manual/en/pcntl.installation.php + * @return void + */ + protected function registerInterruptHandler(): void + { + // pcntl won't be available in web server environment, so skip immediately. + if (! $this->isCli()) { + return; + } + + // Extension pcntl doesn't come with PHP by default, so check if the proper function is available. + if (! function_exists('pcntl_signal')) { + $message = 'pcntl related functions not available, skipping registering interrupt handler.'; + $this->logger->info($message); + $this->state->addStatusMessage($message); + return; + } + + pcntl_signal(SIGINT, [$this, 'handleInterrupt']); + pcntl_signal(SIGTERM, [$this, 'handleInterrupt']); + } + + /** + * @throws Exception + */ + protected function handleInterrupt(int $signal): void + { + $message = sprintf('Gracefully stopping job processing. Interrupt signal was %s.', $signal); + $this->state->addStatusMessage($message); + $this->logger->info($message); + $this->state->setIsGracefulInterruptInitiated(true); + $this->updateCachedState($this->state); + } + + protected function resolveMaximumExecutionTime(): ?\DateInterval + { + $maximumExecutionTime = $this->moduleConfiguration->getJobRunnerMaximumExecutionTime(); + + // If we are in CLI environment, we can safely use module configuration setting. + if ($this->isCli()) { + return $maximumExecutionTime; + } + + // We are in a "web" environment, so take max execution time ini setting into account. + $iniMaximumExecutionTimeSeconds = (int)floor((int)ini_get('max_execution_time') * 0.8); + $iniMaximumExecutionTime = new \DateInterval('PT' . $iniMaximumExecutionTimeSeconds . 'S'); + + // If the module setting is null (meaning infinite), use the ini setting. + if ($maximumExecutionTime === null) { + return $iniMaximumExecutionTime; + } + + // Use the shorter interval from the two... + $maximumExecutionTimeSeconds = $this->helpersManager + ->getDateTimeHelper() + ->convertDateIntervalToSeconds($maximumExecutionTime); + + if ($iniMaximumExecutionTimeSeconds < $maximumExecutionTimeSeconds) { + $this->logger->debug('Using maximum execution time from INI setting since it is shorter.'); + return $iniMaximumExecutionTime; + } + + return $maximumExecutionTime; + } + + protected function isMaximumExecutionTimeReached(): bool + { + if ($this->maximumExecutionTime === null) { + // Execution time is infinite. + return false; + } + + $startedAt = $this->state->getStartedAt(); + if ($startedAt === null) { + // Processing has not even started yet. + return false; + } + + $maxDateTime = $startedAt->add($this->maximumExecutionTime); + if ($maxDateTime > (new \DateTimeImmutable())) { + // Maximum has not been reached yet. + return false; + } + + // Maximum has been reached. + return true; + } +} diff --git a/src/Services/JobRunner/RateLimiter.php b/src/Services/JobRunner/RateLimiter.php new file mode 100644 index 0000000000000000000000000000000000000000..bc8dc55b7845250c45753cd71cf647536ed97263 --- /dev/null +++ b/src/Services/JobRunner/RateLimiter.php @@ -0,0 +1,76 @@ +<?php + +namespace SimpleSAML\Module\accounting\Services\JobRunner; + +use SimpleSAML\Module\accounting\Services\HelpersManager; + +class RateLimiter +{ + public const DEFAULT_MAX_PAUSE_DURATION = 'PT10M'; + public const DEFAULT_MAX_BACKOFF_PAUSE_DURATION = 'PT1M'; + + protected HelpersManager $helpersManager; + + protected int $maxPauseInSeconds; + protected int $maxBackoffPauseInSeconds; + protected int $currentBackoffPauseInSeconds = 1; + + public function __construct( + \DateInterval $maxPauseInterval = null, + \DateInterval $maxBackoffInterval = null, + HelpersManager $helpersManager = null + ) { + $this->helpersManager = $helpersManager ?? new HelpersManager(); + + $this->maxPauseInSeconds = $this->helpersManager->getDateTimeHelper()->convertDateIntervalToSeconds( + $maxPauseInterval ?? new \DateInterval(self::DEFAULT_MAX_PAUSE_DURATION) + ); + $this->maxBackoffPauseInSeconds = $this->helpersManager->getDateTimeHelper()->convertDateIntervalToSeconds( + $maxBackoffInterval ?? new \DateInterval(self::DEFAULT_MAX_BACKOFF_PAUSE_DURATION) + ); + } + + public function doBackoffPause(): void + { + /** @psalm-suppress ArgumentTypeCoercion */ + sleep($this->currentBackoffPauseInSeconds); + + $newBackoffPauseInSeconds = $this->currentBackoffPauseInSeconds + $this->currentBackoffPauseInSeconds; + $this->currentBackoffPauseInSeconds = min($newBackoffPauseInSeconds, $this->maxBackoffPauseInSeconds); + } + + public function doPause(int $seconds = 1): void + { + $seconds = $seconds > 0 ? $seconds : 1; + sleep($seconds); + } + + public function resetBackoffPause(): void + { + $this->currentBackoffPauseInSeconds = 1; + } + + /** + * @return int + */ + public function getMaxPauseInSeconds(): int + { + return $this->maxPauseInSeconds; + } + + /** + * @return int + */ + public function getMaxBackoffPauseInSeconds(): int + { + return $this->maxBackoffPauseInSeconds; + } + + /** + * @return int + */ + public function getCurrentBackoffPauseInSeconds(): int + { + return $this->currentBackoffPauseInSeconds; + } +} diff --git a/src/Services/JobRunner/State.php b/src/Services/JobRunner/State.php new file mode 100644 index 0000000000000000000000000000000000000000..3d1704927002a2464d3b06bc0f73b8da8f8c2246 --- /dev/null +++ b/src/Services/JobRunner/State.php @@ -0,0 +1,202 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Services\JobRunner; + +class State +{ + public const MAX_NUMBER_OF_MESSAGES_TO_KEEP = 100; + public const DEFAULT_NUMBER_OF_MESSAGES_TO_KEEP = 10; + + protected int $jobRunnerId; + protected ?\DateTimeImmutable $startedAt; + protected \DateTimeImmutable $updatedAt; + protected ?\DateTimeImmutable $endedAt = null; + protected int $successfulJobsProcessed = 0; + protected int $failedJobsProcessed = 0; + /** + * @var string[] + */ + protected array $statusMessages = []; + protected int $numberOfStatusMessagesToKeep = 10; + protected bool $isGracefulInterruptInitiated = false; + + public function __construct( + int $jobRunnerId, + \DateTimeImmutable $startedAt = null, + \DateTimeImmutable $updatedAt = null, + int $numberOfStatusMessagesToKeep = self::DEFAULT_NUMBER_OF_MESSAGES_TO_KEEP + ) { + $this->jobRunnerId = $jobRunnerId; + $this->startedAt = $startedAt; + $this->updatedAt = $updatedAt ?? new \DateTimeImmutable(); + + $this->numberOfStatusMessagesToKeep = + $numberOfStatusMessagesToKeep > 0 && $numberOfStatusMessagesToKeep <= self::MAX_NUMBER_OF_MESSAGES_TO_KEEP ? + $numberOfStatusMessagesToKeep : + self::DEFAULT_NUMBER_OF_MESSAGES_TO_KEEP; + } + + /** + * @return int + */ + public function getJobRunnerId(): int + { + return $this->jobRunnerId; + } + + /** + * @return ?\DateTimeImmutable + */ + public function getStartedAt(): ?\DateTimeImmutable + { + return $this->startedAt; + } + + /** + * Set startedAt if not already set. + * @param \DateTimeImmutable $startedAt + * @return bool True if set, false otherwise. + */ + public function setStartedAt(\DateTimeImmutable $startedAt): bool + { + if ($this->startedAt === null) { + $this->startedAt = $startedAt; + return true; + } + + return false; + } + + /** + * @return \DateTimeImmutable + */ + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + /** + * @param \DateTimeImmutable $updatedAt + */ + public function setUpdatedAt(\DateTimeImmutable $updatedAt): void + { + $this->updatedAt = $updatedAt; + } + + /** + * Set endedAt if not already set. + * @param \DateTimeImmutable $endedAt + * @return bool True if set, false otherwise. + */ + public function setEndedAt(\DateTimeImmutable $endedAt): bool + { + if ($this->endedAt === null) { + $this->endedAt = $endedAt; + return true; + } + + return false; + } + + /** + * @return ?\DateTimeImmutable + */ + public function getEndedAt(): ?\DateTimeImmutable + { + return $this->endedAt; + } + + public function hasRunStarted(): bool + { + return $this->startedAt !== null; + } + + public function incrementSuccessfulJobsProcessed(): void + { + $this->successfulJobsProcessed++; + } + + public function incrementFailedJobsProcessed(): void + { + $this->failedJobsProcessed++; + } + + /** + * @return int + */ + public function getSuccessfulJobsProcessed(): int + { + return $this->successfulJobsProcessed; + } + + /** + * @return int + */ + public function getFailedJobsProcessed(): int + { + return $this->failedJobsProcessed; + } + + public function isStale(\DateInterval $threshold): bool + { + $minDateTime = (new \DateTimeImmutable())->sub($threshold); + + if ($this->getUpdatedAt() < $minDateTime) { + return true; + } + + return false; + } + + public function getTotalJobsProcessed(): int + { + return $this->getSuccessfulJobsProcessed() + $this->getFailedJobsProcessed(); + } + + public function addStatusMessage(string $message): void + { + $this->statusMessages[] = $message; + + if (count($this->statusMessages) > $this->numberOfStatusMessagesToKeep) { + array_shift($this->statusMessages); + } + } + + /** + * @return string[] + */ + public function getStatusMessages(): array + { + return $this->statusMessages; + } + + public function getLastStatusMessage(): ?string + { + if (empty($this->statusMessages)) { + return null; + } + + $message = end($this->statusMessages); + reset($this->statusMessages); + + return $message; + } + + /** + * @return bool + */ + public function getIsGracefulInterruptInitiated(): bool + { + return $this->isGracefulInterruptInitiated; + } + + /** + * @param bool $isGracefulInterruptInitiated + */ + public function setIsGracefulInterruptInitiated(bool $isGracefulInterruptInitiated): void + { + $this->isGracefulInterruptInitiated = $isGracefulInterruptInitiated; + } +} diff --git a/src/Services/Logger.php b/src/Services/Logger.php new file mode 100644 index 0000000000000000000000000000000000000000..c26c296ba52fbbedcfe722bc0a007cca795cdf8c --- /dev/null +++ b/src/Services/Logger.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Services; + +use Psr\Log\AbstractLogger; +use Psr\Log\LogLevel; +use SimpleSAML\Logger as SspLogger; + +class Logger extends AbstractLogger +{ + public function log($level, $message, array $context = [], string $prefix = '(accounting) ') + { + $message = $prefix . $message; + + if (! empty($context)) { + $message .= ' Context: ' . var_export($context, true); + } + + switch ($level) { + case LogLevel::EMERGENCY: + SspLogger::emergency($message); + break; + case LogLevel::CRITICAL: + SspLogger::critical($message); + break; + case LogLevel::ALERT: + SspLogger::alert($message); + break; + case LogLevel::ERROR: + SspLogger::error($message); + break; + case LogLevel::WARNING: + SspLogger::warning($message); + break; + case LogLevel::NOTICE: + SspLogger::notice($message); + break; + case LogLevel::INFO: + SspLogger::info($message); + break; + case LogLevel::DEBUG: + SspLogger::debug($message); + break; + } + } + + /** + * Log an SSP statistics message. + * + * @param string $message The message to log. + */ + public function stats(string $message): void + { + SspLogger::stats($message); + } +} diff --git a/src/Stores/Bases/AbstractStore.php b/src/Stores/Bases/AbstractStore.php new file mode 100644 index 0000000000000000000000000000000000000000..09b5ff6becf1754a0effa93f01fe6f9ffa1a6dcf --- /dev/null +++ b/src/Stores/Bases/AbstractStore.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Bases; + +use Psr\Log\LoggerInterface; +use ReflectionClass; +use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface; +use SimpleSAML\Module\accounting\Interfaces\SetupableInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +abstract class AbstractStore implements BuildableUsingModuleConfigurationInterface, SetupableInterface +{ + protected ModuleConfiguration $moduleConfiguration; + protected LoggerInterface $logger; + protected string $connectionKey; + + /** + */ + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER + ) { + $this->moduleConfiguration = $moduleConfiguration; + $this->logger = $logger; + + $this->connectionKey = $connectionKey ?? + $moduleConfiguration->getClassConnectionKey($this->getSelfClass(), $connectionType); + } + + /** + * Get ReflectionClass of current store instance. + * @return ReflectionClass + */ + protected function getReflection(): ReflectionClass + { + return new ReflectionClass($this); + } + + /** + * Get class of the current store instance. + * @return string + */ + protected function getSelfClass(): string + { + return $this->getReflection()->getName(); + } + + /** + * Build store instance. Must be implemented in child classes for proper return store type. + * @param ModuleConfiguration $moduleConfiguration + * @param LoggerInterface $logger + * @param string|null $connectionKey + * @return self + */ + abstract public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null + ): self; +} diff --git a/src/Stores/Bases/DoctrineDbal/AbstractRawEntity.php b/src/Stores/Bases/DoctrineDbal/AbstractRawEntity.php new file mode 100644 index 0000000000000000000000000000000000000000..ac0b534bb0553f66b5360b82eabbd0b657250587 --- /dev/null +++ b/src/Stores/Bases/DoctrineDbal/AbstractRawEntity.php @@ -0,0 +1,51 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal; + +use DateTimeImmutable; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use Throwable; + +abstract class AbstractRawEntity +{ + protected array $rawRow; + protected AbstractPlatform $abstractPlatform; + + public function __construct(array $rawRow, AbstractPlatform $abstractPlatform) + { + $this->rawRow = $rawRow; + $this->abstractPlatform = $abstractPlatform; + + $this->validate($rawRow); + } + + /** + * @throws UnexpectedValueException + */ + abstract protected function validate(array $rawRow): void; + + /** + * @param mixed $value + * @return DateTimeImmutable + */ + protected function resolveDateTimeImmutable($value): DateTimeImmutable + { + try { + /** @var DateTimeImmutable $dateTimeImmutable */ + $dateTimeImmutable = (Type::getType(Types::DATETIME_IMMUTABLE)) + ->convertToPHPValue($value, $this->abstractPlatform); + } catch (Throwable $exception) { + $message = sprintf( + 'Could not create DateTimeImmutable using value %s. Error was: %s.', + var_export($value, true), + $exception->getMessage() + ); + throw new UnexpectedValueException($message, (int)$exception->getCode(), $exception); + } + + return $dateTimeImmutable; + } +} diff --git a/src/Stores/Bases/DoctrineDbal/AbstractStore.php b/src/Stores/Bases/DoctrineDbal/AbstractStore.php new file mode 100644 index 0000000000000000000000000000000000000000..7dabc9872ec576a5dcb60a8e0d9add99e0bcf0ee --- /dev/null +++ b/src/Stores/Bases/DoctrineDbal/AbstractStore.php @@ -0,0 +1,118 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal; + +use Psr\Log\LoggerInterface; +use ReflectionClass; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; + +abstract class AbstractStore extends \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore +{ + protected Connection $connection; + protected Migrator $migrator; + protected Factory $connectionFactory; + + /** + * @throws StoreException + */ + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER, + Factory $connectionFactory = null + ) { + parent::__construct($moduleConfiguration, $logger, $connectionKey, $connectionType); + + $this->connectionFactory = $connectionFactory ?? new Factory($this->moduleConfiguration, $this->logger); + + $this->connection = $this->connectionFactory->buildConnection($this->connectionKey); + $this->migrator = $this->connectionFactory->buildMigrator($this->connection); + } + + protected function getMigrationsNamespace(): string + { + return $this->getSelfClass() . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME; + } + + protected function areAllMigrationsImplemented(): bool + { + return !$this->migrator->hasNonImplementedMigrationClasses( + $this->getMigrationsDirectory(), + $this->getMigrationsNamespace() + ); + } + + /** + * @throws StoreException + * @throws MigrationException + */ + public function runSetup(): void + { + if ($this->migrator->needsSetup()) { + $this->migrator->runSetup(); + } + + if (!$this->areAllMigrationsImplemented()) { + $this->migrator->runNonImplementedMigrationClasses( + $this->getMigrationsDirectory(), + $this->getMigrationsNamespace() + ); + } + } + + /** + * @throws StoreException + */ + public function needsSetup(): bool + { + // ... if the migrator itself needs setup. + if ($this->migrator->needsSetup()) { + return true; + } + + // ... if Store migrations need to run + if (!$this->areAllMigrationsImplemented()) { + return true; + } + + return false; + } + + /** + * Get migrations directory. + * By default, it will return {...}/Store/Migrations directory. + * @return string + */ + protected function getMigrationsDirectory(): string + { + $reflection = $this->getReflection(); + $storeDirName = dirname($reflection->getFileName()); + $storeShortName = $reflection->getShortName(); + + return $storeDirName . DIRECTORY_SEPARATOR . + $storeShortName . DIRECTORY_SEPARATOR . + AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME; + } + + /** + * Build store instance. Must be implemented in child classes for proper return store type. + * @param ModuleConfiguration $moduleConfiguration + * @param LoggerInterface $logger + * @param string|null $connectionKey + * @return self + */ + abstract public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null + ): self; +} diff --git a/src/Stores/Builders/Bases/AbstractStoreBuilder.php b/src/Stores/Builders/Bases/AbstractStoreBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..5cce33d23c7d4e885ede1e5d9e4af5316e94ec2d --- /dev/null +++ b/src/Stores/Builders/Bases/AbstractStoreBuilder.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Builders\Bases; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Interfaces\StoreInterface; +use Throwable; + +use function sprintf; +use function is_subclass_of; + +abstract class AbstractStoreBuilder +{ + protected ModuleConfiguration $moduleConfiguration; + protected LoggerInterface $logger; + protected HelpersManager $helpersManager; + + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + HelpersManager $helpersManager + ) { + $this->moduleConfiguration = $moduleConfiguration; + $this->logger = $logger; + $this->helpersManager = $helpersManager; + } + + abstract public function build( + string $class, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER + ): StoreInterface; + + /** + * @throws StoreException + */ + protected function buildGeneric(string $class, array $additionalArguments = []): StoreInterface + { + try { + // Make sure that the class implements StoreInterface + if (!is_subclass_of($class, StoreInterface::class)) { + throw new StoreException(sprintf('Class %s does not implement StoreInterface.', $class)); + } + + // Build store... + /** @var StoreInterface $store */ + $store = $this->helpersManager->getInstanceBuilderUsingModuleConfigurationHelper()->build( + $class, + $this->moduleConfiguration, + $this->logger, + $additionalArguments + ); + } catch (Throwable $exception) { + $message = sprintf('Error building store for class %s. Error was: %s', $class, $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + return $store; + } +} diff --git a/src/Stores/Builders/DataStoreBuilder.php b/src/Stores/Builders/DataStoreBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..857b447f91ed1e51a7f61752e4023b231efffa94 --- /dev/null +++ b/src/Stores/Builders/DataStoreBuilder.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Builders; + +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration\ConnectionType; +use SimpleSAML\Module\accounting\Stores\Interfaces\DataStoreInterface; + +class DataStoreBuilder extends Bases\AbstractStoreBuilder +{ + /** + * @throws StoreException + */ + public function build( + string $class, + string $connectionKey = null, + string $connectionType = ConnectionType::MASTER + ): DataStoreInterface { + if (!is_subclass_of($class, DataStoreInterface::class)) { + throw new StoreException( + sprintf('Class \'%s\' does not implement interface \'%s\'.', $class, DataStoreInterface::class) + ); + } + + /** @var DataStoreInterface $store */ + $store = $this->buildGeneric($class, [$connectionKey, $connectionType]); + + return $store; + } +} diff --git a/src/Stores/Builders/JobsStoreBuilder.php b/src/Stores/Builders/JobsStoreBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..9ad37849728be1192c9873e36f6326b00e641d45 --- /dev/null +++ b/src/Stores/Builders/JobsStoreBuilder.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Builders; + +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration\ConnectionType; +use SimpleSAML\Module\accounting\Stores\Interfaces\JobsStoreInterface; + +use function sprintf; + +class JobsStoreBuilder extends Bases\AbstractStoreBuilder +{ + /** + * @throws StoreException + */ + public function build( + string $class, + string $connectionKey = null, + string $connectionType = ConnectionType::MASTER + ): JobsStoreInterface { + if (!is_subclass_of($class, JobsStoreInterface::class)) { + throw new StoreException( + sprintf('Class \'%s\' does not implement interface \'%s\'.', $class, JobsStoreInterface::class) + ); + } + + $connectionKey = $connectionKey ?? $this->moduleConfiguration->getClassConnectionKey($class); + + /** @var JobsStoreInterface $store */ + $store = $this->buildGeneric($class, [$connectionKey, $connectionType]); + + return $store; + } +} diff --git a/src/Stores/Connections/Bases/AbstractMigrator.php b/src/Stores/Connections/Bases/AbstractMigrator.php new file mode 100644 index 0000000000000000000000000000000000000000..2cdb590c78825979a5895e3dd80a4308e57d7270 --- /dev/null +++ b/src/Stores/Connections/Bases/AbstractMigrator.php @@ -0,0 +1,140 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Connections\Bases; + +use SimpleSAML\Module\accounting\Exceptions\InvalidValueException; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Helpers\FilesystemHelper; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Interfaces\MigrationInterface; +use Throwable; + +abstract class AbstractMigrator +{ + public const DEFAULT_MIGRATIONS_DIRECTORY_NAME = 'Migrations'; + + protected HelpersManager $helpersManager; + + public function __construct(HelpersManager $helpersManager = null) + { + $this->helpersManager = $helpersManager ?? new HelpersManager(); + } + + /** + * @param string $directory + * @param string $namespace + * @return class-string[] + */ + public function gatherMigrationClassesFromDirectory(string $directory, string $namespace): array + { + $directory = $this->helpersManager->getFilesystemHelper()->getRealPath($directory); + + // Get files without dot directories + $files = array_values(array_diff(scandir($directory), ['..', '.'])); + + array_walk($files, function (string &$file) use ($namespace) { + // Remove .php extension from filename + $file = basename($file, '.php'); + // Prepend namespace for each entry + $file = $namespace . '\\' . $file; + }); + + // Migration classes must follow proper interfaces, so do validate each of them and discard invalid ones. + /** @var class-string[] $migrationClasses */ + $migrationClasses = array_filter($files, function (string $file) { + try { + $this->validateMigrationClass($file); + return true; + } catch (InvalidValueException $exception) { + return false; + } + }); + + return $migrationClasses; + } + + /** + * @param class-string[] $migrationClasses + * @return void + * @throws MigrationException + */ + public function runMigrationClasses(array $migrationClasses): void + { + foreach ($migrationClasses as $migrationClass) { + $this->validateMigrationClass($migrationClass); + + $migration = $this->buildMigrationClassInstance($migrationClass); + + try { + $migration->run(); + } catch (Throwable $exception) { + $message = sprintf( + 'Could not run migration class %s. Error was: %s', + $migrationClass, + $exception->getMessage() + ); + + throw new MigrationException($message, (int) $exception->getCode(), $exception); + } + + $this->markImplementedMigrationClass($migrationClass); + } + } + + /** + * @return class-string[] + */ + public function getNonImplementedMigrationClasses(string $directory, string $namespace): array + { + return array_diff( + $this->gatherMigrationClassesFromDirectory($directory, $namespace), + $this->getImplementedMigrationClasses() + ); + } + + /** + * @param string $directory + * @param string $namespace + * @return bool + */ + public function hasNonImplementedMigrationClasses(string $directory, string $namespace): bool + { + return ! empty($this->getNonImplementedMigrationClasses($directory, $namespace)); + } + + /** + * @throws MigrationException + */ + public function runNonImplementedMigrationClasses(string $directory, string $namespace): void + { + $this->runMigrationClasses($this->getNonImplementedMigrationClasses($directory, $namespace)); + } + + public function validateMigrationClass(string $migrationClass): void + { + if (! is_subclass_of($migrationClass, MigrationInterface::class)) { + throw new InvalidValueException( + sprintf('Migration class does not implement MigrationInterface (%s)', $migrationClass) + ); + } + } + + /** + * @param class-string $migrationClass + * @return MigrationInterface + */ + abstract protected function buildMigrationClassInstance(string $migrationClass): MigrationInterface; + + /** + * @param class-string $migrationClass + * @return void + */ + abstract protected function markImplementedMigrationClass(string $migrationClass): void; + + /** + * @return class-string[] + */ + abstract public function getImplementedMigrationClasses(): array; +} diff --git a/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigration.php b/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigration.php new file mode 100644 index 0000000000000000000000000000000000000000..9619f19bb7b4a90d9175a18608930e15f160d7c4 --- /dev/null +++ b/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigration.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Interfaces\MigrationInterface; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use Throwable; + +abstract class AbstractMigration implements MigrationInterface +{ + protected Connection $connection; + protected AbstractSchemaManager $schemaManager; + + /** + * @throws StoreException + */ + public function __construct(Connection $connection) + { + $this->connection = $connection; + try { + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + } catch (Throwable $exception) { + $message = 'Could not create DBAL schema manager.'; + throw new StoreException($message, (int) $exception->getCode(), $exception); + } + } + + protected function prepareGenericMigrationException( + string $contextDetails, + Throwable $throwable + ): MigrationException { + $message = sprintf( + 'There was an error running a migration class %s. Context details: %s. Error was: %s.', + static::class, + $contextDetails, + $throwable->getMessage() + ); + + return new MigrationException($message, (int) $throwable->getCode(), $throwable); + } + + /** + * Prepare prefixed table name which will include table prefix from connection, local table prefix, and table name. + * + * @param string $tableName + * @return string + */ + protected function preparePrefixedTableName(string $tableName): string + { + return $this->connection->preparePrefixedTableName($this->getLocalTablePrefix() . $tableName); + } + + /** + * Get local table prefix (prefix per migration). Empty string by default. Override in particular migration to + * set another local prefix. + * + * @return string + */ + protected function getLocalTablePrefix(): string + { + return ''; + } +} diff --git a/src/Stores/Connections/DoctrineDbal/Connection.php b/src/Stores/Connections/DoctrineDbal/Connection.php new file mode 100644 index 0000000000000000000000000000000000000000..69c6202cb4c370a4ff7af039c8d1730a11786491 --- /dev/null +++ b/src/Stores/Connections/DoctrineDbal/Connection.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal; + +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Exception; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\Stores\Interfaces\ConnectionInterface; + +class Connection implements ConnectionInterface +{ + public const PARAMETER_TABLE_PREFIX = 'table_prefix'; + + protected \Doctrine\DBAL\Connection $dbal; + protected ?string $tablePrefix; + + public function __construct(array $parameters) + { + try { + /** @psalm-suppress MixedArgumentTypeCoercion */ + $this->dbal = DriverManager::getConnection($parameters); + } catch (Exception $e) { + throw new InvalidConfigurationException( + 'Could not initiate Doctrine DBAL connection with given parameters.' + ); + } + + $this->tablePrefix = $this->getTablePrefixFromParameters($parameters); + } + + public function getTablePrefix(): string + { + return $this->tablePrefix ?? ''; + } + + public function dbal(): \Doctrine\DBAL\Connection + { + return $this->dbal; + } + + protected function getTablePrefixFromParameters(array $settings): ?string + { + if (! isset($settings[self::PARAMETER_TABLE_PREFIX])) { + return null; + } + + if (! is_string($settings[self::PARAMETER_TABLE_PREFIX])) { + throw new InvalidConfigurationException('Connection table prefix must be string (if set).'); + } + + return $settings[self::PARAMETER_TABLE_PREFIX]; + } + + public function preparePrefixedTableName(string $tableName): string + { + return $this->getTablePrefix() . $tableName; + } +} diff --git a/src/Stores/Connections/DoctrineDbal/Factory.php b/src/Stores/Connections/DoctrineDbal/Factory.php new file mode 100644 index 0000000000000000000000000000000000000000..b447dbf13c6b356c6f94343e116c4081b6227a9b --- /dev/null +++ b/src/Stores/Connections/DoctrineDbal/Factory.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +class Factory +{ + protected ModuleConfiguration $moduleConfiguration; + protected LoggerInterface $loggerService; + + public function __construct(ModuleConfiguration $moduleConfiguration, LoggerInterface $loggerService) + { + $this->moduleConfiguration = $moduleConfiguration; + $this->loggerService = $loggerService; + } + + public function buildConnection(string $connectionKey): Connection + { + return new Connection($this->moduleConfiguration->getConnectionParameters($connectionKey)); + } + + /** + * @throws StoreException + */ + public function buildMigrator(Connection $connection): Migrator + { + return new Migrator($connection, $this->loggerService); + } +} diff --git a/src/Stores/Connections/DoctrineDbal/Migrator.php b/src/Stores/Connections/DoctrineDbal/Migrator.php new file mode 100644 index 0000000000000000000000000000000000000000..80464462def1241e26a7e54ccee8f60c323f94d3 --- /dev/null +++ b/src/Stores/Connections/DoctrineDbal/Migrator.php @@ -0,0 +1,182 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal; + +use DateTimeImmutable; +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Types\Types; +use Psr\Log\LoggerInterface; +use ReflectionClass; +use ReflectionException; +use SimpleSAML\Module\accounting\Exceptions\InvalidValueException; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Interfaces\MigrationInterface; +use Throwable; + +class Migrator extends AbstractMigrator +{ + public const TABLE_NAME = 'migrations'; + + public const COLUMN_NAME_ID = 'id'; + public const COLUMN_NAME_VERSION = 'version'; + public const COLUMN_NAME_CREATED_AT = 'created_at'; + + protected Connection $connection; + protected LoggerInterface $logger; + + protected AbstractSchemaManager $schemaManager; + protected string $prefixedTableName; + + /** + * @throws StoreException + */ + public function __construct(Connection $connection, LoggerInterface $logger, HelpersManager $helpersManager = null) + { + parent::__construct($helpersManager); + + $this->connection = $connection; + $this->logger = $logger; + + try { + $this->schemaManager = ($this->connection->dbal())->createSchemaManager(); + } catch (Throwable $exception) { + $message = sprintf('Could not create DBAL schema manager. Error was: %s', $exception->getMessage()); + throw new StoreException($message, (int) $exception->getCode(), $exception); + } + $this->prefixedTableName = $this->connection->preparePrefixedTableName(self::TABLE_NAME); + } + + /** + * @throws StoreException + */ + public function needsSetup(): bool + { + try { + return ! $this->schemaManager->tablesExist([$this->prefixedTableName]); + } catch (Throwable $exception) { + $message = sprintf( + 'Could not check table \'%s\' existence using schema manager. Error was:%s', + $this->prefixedTableName, + $exception->getMessage() + ); + throw new StoreException($message, (int) $exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function runSetup(): void + { + if (! $this->needsSetup()) { + $this->logger->warning('Migrator setup has been called, however setup is not needed.'); + return; + } + + $this->createMigrationsTable(); + } + + /** + * @throws StoreException + */ + protected function createMigrationsTable(): void + { + try { + $table = new Table($this->prefixedTableName); + + $table->addColumn(self::COLUMN_NAME_ID, Types::BIGINT) + ->setAutoincrement(true) + ->setUnsigned(true); + $table->addColumn(self::COLUMN_NAME_VERSION, Types::STRING); + $table->addColumn(self::COLUMN_NAME_CREATED_AT, Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex([self::COLUMN_NAME_VERSION]); + + $this->schemaManager->createTable($table); + } catch (Throwable $exception) { + $message = sprintf('Error creating migrations table %s.', $this->prefixedTableName); + throw new StoreException($message, (int) $exception->getCode(), $exception); + } + } + + /** + * @throws ReflectionException + */ + protected function buildMigrationClassInstance(string $migrationClass): MigrationInterface + { + $this->validateDoctrineDbalMigrationClass($migrationClass); + + /** @var MigrationInterface $migration */ + $migration = (new ReflectionClass($migrationClass))->newInstance($this->connection); + + return $migration; + } + + protected function validateDoctrineDbalMigrationClass(string $migrationClass): void + { + if (! is_subclass_of($migrationClass, AbstractMigration::class)) { + throw new InvalidValueException('Migration class is not Doctrine DBAL migration.'); + } + } + + /** + * @throws StoreException + */ + protected function markImplementedMigrationClass(string $migrationClass): void + { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + try { + $queryBuilder->insert($this->prefixedTableName) + ->values( + [ + self::COLUMN_NAME_VERSION => ':' . self::COLUMN_NAME_VERSION, + self::COLUMN_NAME_CREATED_AT => ':' . self::COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + self::COLUMN_NAME_VERSION => $migrationClass, + self::COLUMN_NAME_CREATED_AT => new DateTimeImmutable(), + ], + [ + self::COLUMN_NAME_VERSION => Types::STRING, + self::COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE + ] + ); + + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf('Error marking implemented migrations class %s.', $migrationClass); + throw new StoreException($message, (int) $exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getImplementedMigrationClasses(): array + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $queryBuilder->select(self::COLUMN_NAME_VERSION) + ->from($this->prefixedTableName); + + /** @var class-string[] $migrationClasses */ + $migrationClasses = $queryBuilder->executeQuery()->fetchFirstColumn(); + } catch (Throwable $exception) { + $message = 'Error getting implemented migration classes.'; + throw new StoreException($message, (int) $exception->getCode(), $exception); + } + + return $migrationClasses; + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store.php new file mode 100644 index 0000000000000000000000000000000000000000..ce3478f1ed114d9a0734807e12326ddc285654c3 --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store.php @@ -0,0 +1,557 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Activity; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; +use SimpleSAML\Module\accounting\Entities\ServiceProvider; +use SimpleSAML\Module\accounting\Entities\User; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\HashDecoratedState; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawActivity; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawConnectedServiceProvider; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository; +use SimpleSAML\Module\accounting\Stores\Interfaces\DataStoreInterface; + +class Store extends AbstractStore implements DataStoreInterface +{ + protected Repository $repository; + protected HelpersManager $helpersManager; + + /** + * @throws StoreException + */ + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER, + Factory $connectionFactory = null, + Repository $repository = null, + HelpersManager $helpersManager = null + ) { + parent::__construct($moduleConfiguration, $logger, $connectionKey, $connectionType, $connectionFactory); + + $this->repository = $repository ?? new Repository($this->connection, $this->logger); + $this->helpersManager = $helpersManager ?? new HelpersManager(); + } + + /** + * Build store instance. + * @throws StoreException + */ + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER + ): self { + return new self( + $moduleConfiguration, + $logger, + $connectionKey, + $connectionType + ); + } + + /** + * @throws StoreException + */ + public function persist(Event $authenticationEvent): void + { + $hashDecoratedState = new HashDecoratedState($authenticationEvent->getState()); + + $idpId = $this->resolveIdpId($hashDecoratedState); + $idpVersionId = $this->resolveIdpVersionId($idpId, $hashDecoratedState); + $spId = $this->resolveSpId($hashDecoratedState); + $spVersionId = $this->resolveSpVersionId($spId, $hashDecoratedState); + $userId = $this->resolveUserId($hashDecoratedState); + $userVersionId = $this->resolveUserVersionId($userId, $hashDecoratedState); + $idpSpUserVersionId = $this->resolveIdpSpUserVersionId($idpVersionId, $spVersionId, $userVersionId); + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $authenticationEvent->getHappenedAt(), + $authenticationEvent->getState()->getClientIpAddress() + ); + } + + /** + * @throws StoreException + */ + protected function resolveIdpId(HashDecoratedState $hashDecoratedState): int + { + $idpEntityIdHashSha256 = $hashDecoratedState->getIdentityProviderEntityIdHashSha256(); + + // Check if it already exists. + try { + $result = $this->repository->getIdp($idpEntityIdHashSha256); + $idpId = $result->fetchOne(); + + if ($idpId !== false) { + return (int)$idpId; + } + } catch (\Throwable $exception) { + $message = sprintf('Error resolving Idp ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + // Create new + try { + $this->repository->insertIdp( + $hashDecoratedState->getState()->getIdentityProviderEntityId(), + $idpEntityIdHashSha256 + ); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error inserting new IdP, however, continuing in case of race condition. Error was: %s.', + $exception->getMessage() + ); + $this->logger->warning($message); + } + + // Try again, this time it should exist... + try { + $result = $this->repository->getIdp($idpEntityIdHashSha256); + $idpIdNew = $result->fetchOne(); + + if ($idpIdNew !== false) { + return (int)$idpIdNew; + } + + $message = sprintf( + 'Error fetching IdP ID even after insertion for entity ID hash SHA256 %s.', + $idpEntityIdHashSha256 + ); + throw new StoreException($message); + } catch (\Throwable $exception) { + $message = sprintf('Error resolving Idp ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + protected function resolveIdpVersionId(int $idpId, HashDecoratedState $hashDecoratedState): int + { + // Check if it already exists. + $idpMetadataArrayHashSha256 = $hashDecoratedState->getIdentityProviderMetadataArrayHashSha256(); + + try { + $result = $this->repository->getIdpVersion($idpId, $idpMetadataArrayHashSha256); + $idpVersionId = $result->fetchOne(); + + if ($idpVersionId !== false) { + return (int)$idpVersionId; + } + } catch (\Throwable $exception) { + $message = sprintf('Error resolving IdP Version ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + // Create new + try { + $this->repository->insertIdpVersion( + $idpId, + serialize($hashDecoratedState->getState()->getIdentityProviderMetadata()), + $idpMetadataArrayHashSha256 + ); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error inserting new IdP Version, however, continuing in case of race condition. Error was: %s.', + $exception->getMessage() + ); + $this->logger->warning($message); + } + + // Try again, this time it should exist... + try { + $result = $this->repository->getIdpVersion($idpId, $idpMetadataArrayHashSha256); + $idpVersionIdNew = $result->fetchOne(); + + if ($idpVersionIdNew !== false) { + return (int)$idpVersionIdNew; + } + + $message = sprintf( + 'Error fetching IdP ID Version even after insertion for Idp ID %s.', + $idpId + ); + throw new StoreException($message); + } catch (\Throwable $exception) { + $message = sprintf('Error resolving Idp Version ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + protected function resolveSpId(HashDecoratedState $hashDecoratedState): int + { + $spEntityIdHashSha256 = $hashDecoratedState->getServiceProviderEntityIdHashSha256(); + + // Check if it already exists. + try { + $result = $this->repository->getSp($spEntityIdHashSha256); + $spId = $result->fetchOne(); + + if ($spId !== false) { + return (int)$spId; + } + } catch (\Throwable $exception) { + $message = sprintf('Error resolving SP ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + // Create new + try { + $this->repository->insertSp( + $hashDecoratedState->getState()->getServiceProviderEntityId(), + $spEntityIdHashSha256 + ); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error inserting new SP, however, continuing in case of race condition. Error was: %s.', + $exception->getMessage() + ); + $this->logger->warning($message); + } + + // Try again, this time it should exist... + try { + $result = $this->repository->getSp($spEntityIdHashSha256); + $spIdNew = $result->fetchOne(); + + if ($spIdNew !== false) { + return (int)$spIdNew; + } + + $message = sprintf( + 'Error fetching SP ID even after insertion for entity ID hash SHA256 %s.', + $spEntityIdHashSha256 + ); + throw new StoreException($message); + } catch (\Throwable $exception) { + $message = sprintf('Error resolving SP ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + protected function resolveSpVersionId(int $spId, HashDecoratedState $hashDecoratedState): int + { + // Check if it already exists. + $spMetadataArrayHashSha256 = $hashDecoratedState->getServiceProviderMetadataArrayHashSha256(); + + try { + $result = $this->repository->getSpVersion($spId, $spMetadataArrayHashSha256); + $spVersionId = $result->fetchOne(); + + if ($spVersionId !== false) { + return (int)$spVersionId; + } + } catch (\Throwable $exception) { + $message = sprintf('Error resolving SP Version ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + // Create new + try { + $this->repository->insertSpVersion( + $spId, + serialize($hashDecoratedState->getState()->getServiceProviderMetadata()), + $spMetadataArrayHashSha256 + ); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error inserting new SP Version, however, continuing in case of race condition. Error was: %s.', + $exception->getMessage() + ); + $this->logger->warning($message); + } + + // Try again, this time it should exist... + try { + $result = $this->repository->getSpVersion($spId, $spMetadataArrayHashSha256); + $spVersionIdNew = $result->fetchOne(); + + if ($spVersionIdNew !== false) { + return (int)$spVersionIdNew; + } + + $message = sprintf( + 'Error fetching SP Version even after insertion for SP ID %s.', + $spId + ); + throw new StoreException($message); + } catch (\Throwable $exception) { + $message = sprintf('Error resolving SP Version ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + protected function resolveUserId(HashDecoratedState $hashDecoratedState): int + { + $userIdentifierAttributeName = $this->moduleConfiguration->getUserIdAttributeName(); + + $userIdentifierValue = $hashDecoratedState->getState()->getAttributeValue($userIdentifierAttributeName); + if ($userIdentifierValue === null) { + $message = sprintf('Attributes do not contain user ID attribute %s.', $userIdentifierAttributeName); + throw new UnexpectedValueException($message); + } + + $userIdentifierValueHashSha256 = $this->helpersManager->getHashHelper()->getSha256($userIdentifierValue); + + // Check if it already exists. + try { + $result = $this->repository->getUser($userIdentifierValueHashSha256); + $userId = $result->fetchOne(); + + if ($userId !== false) { + return (int)$userId; + } + } catch (\Throwable $exception) { + $message = sprintf('Error resolving user ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + // Create new + try { + $this->repository->insertUser($userIdentifierValue, $userIdentifierValueHashSha256); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error inserting new user, however, continuing in case of race condition. Error was: %s.', + $exception->getMessage() + ); + $this->logger->warning($message); + } + + // Try again, this time it should exist... + try { + $result = $this->repository->getUser($userIdentifierValueHashSha256); + $userIdNew = $result->fetchOne(); + + if ($userIdNew !== false) { + return (int)$userIdNew; + } + + $message = sprintf( + 'Error fetching user even after insertion for identifier value hash SHA256 %s.', + $userIdentifierValueHashSha256 + ); + throw new StoreException($message); + } catch (\Throwable $exception) { + $message = sprintf('Error resolving user ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + protected function resolveUserVersionId(int $userId, HashDecoratedState $hashDecoratedState): int + { + $attributeArrayHashSha256 = $hashDecoratedState->getAttributesArrayHashSha256(); + + // Check if it already exists. + try { + $result = $this->repository->getUserVersion($userId, $attributeArrayHashSha256); + $userVersionId = $result->fetchOne(); + + if ($userVersionId !== false) { + return (int)$userVersionId; + } + } catch (\Throwable $exception) { + $message = sprintf('Error resolving user version ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + // Create new + try { + $this->repository->insertUserVersion( + $userId, + serialize($hashDecoratedState->getState()->getAttributes()), + $attributeArrayHashSha256 + ); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error inserting new user version, however, continuing in case of race condition. Error was: %s.', + $exception->getMessage() + ); + $this->logger->warning($message); + } + + // Try again, this time it should exist... + try { + $result = $this->repository->getUserVersion($userId, $attributeArrayHashSha256); + $userVersionIdNew = $result->fetchOne(); + + if ($userVersionIdNew !== false) { + return (int)$userVersionIdNew; + } + + $message = sprintf( + 'Error fetching user version even after insertion for user ID %s.', + $userId + ); + throw new StoreException($message); + } catch (\Throwable $exception) { + $message = sprintf('Error resolving user version ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + protected function resolveIdpSpUserVersionId(int $idpVersionId, int $spVersionId, int $userVersionId): int + { + // Check if it already exists. + try { + $result = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId); + $IdpSpUserVersionId = $result->fetchOne(); + + if ($IdpSpUserVersionId !== false) { + return (int)$IdpSpUserVersionId; + } + } catch (\Throwable $exception) { + $message = sprintf('Error resolving IdpSpUserVersion ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + // Create new + try { + $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error inserting new IdpSpUserVersion, however, continuing in case of race condition. ' . + 'Error was: %s.', + $exception->getMessage() + ); + $this->logger->warning($message); + } + + // Try again, this time it should exist... + try { + $result = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId); + $IdpSpUserVersionIdNew = $result->fetchOne(); + + if ($IdpSpUserVersionIdNew !== false) { + return (int)$IdpSpUserVersionIdNew; + } + + $message = sprintf( + 'Error fetching IdpSpUserVersion ID even after insertion for IdpVersion %s, SpVersion ID %s and ' . + 'UserVersion ID %s.', + $idpVersionId, + $spVersionId, + $userVersionId + ); + throw new StoreException($message); + } catch (\Throwable $exception) { + $message = sprintf('Error resolving IdpSpUserVersion ID. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getConnectedOrganizations(string $userIdentifierHashSha256): ConnectedServiceProvider\Bag + { + $connectedServiceProviderBag = new ConnectedServiceProvider\Bag(); + + $results = $this->repository->getConnectedServiceProviders($userIdentifierHashSha256); + + if (empty($results)) { + return $connectedServiceProviderBag; + } + + try { + $databasePlatform = $this->connection->dbal()->getDatabasePlatform(); + + /** @var array $result */ + foreach ($results as $result) { + $rawConnectedServiceProvider = new RawConnectedServiceProvider($result, $databasePlatform); + + $serviceProvider = new ServiceProvider($rawConnectedServiceProvider->getServiceProviderMetadata()); + $user = new User($rawConnectedServiceProvider->getUserAttributes()); + + $connectedServiceProviderBag->addOrReplace( + new ConnectedServiceProvider( + $serviceProvider, + $rawConnectedServiceProvider->getNumberOfAuthentications(), + $rawConnectedServiceProvider->getLastAuthenticationAt(), + $rawConnectedServiceProvider->getFirstAuthenticationAt(), + $user + ) + ); + } + } catch (\Throwable $exception) { + $message = sprintf( + 'Error populating connected service provider bag. Error was: %s', + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + return $connectedServiceProviderBag; + } + + + /** + * @throws StoreException + */ + public function getActivity(string $userIdentifierHashSha256, int $maxResults, int $firstResult): Activity\Bag + { + $results = $this->repository->getActivity($userIdentifierHashSha256, $maxResults, $firstResult); + + $activityBag = new Activity\Bag(); + + if (empty($results)) { + return $activityBag; + } + + try { + /** @var array $result */ + foreach ($results as $result) { + $rawActivity = new RawActivity($result, $this->connection->dbal()->getDatabasePlatform()); + $serviceProvider = new ServiceProvider($rawActivity->getServiceProviderMetadata()); + $user = new User($rawActivity->getUserAttributes()); + + $activityBag->add( + new Activity( + $serviceProvider, + $user, + $rawActivity->getHappenedAt(), + $rawActivity->getClientIpAddress() + ) + ); + } + } catch (\Throwable $exception) { + $message = sprintf( + 'Error populating activity bag. Error was: %s', + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + return $activityBag; + } + + public function deleteDataOlderThan(\DateTimeImmutable $dateTime): void + { + // Only delete authentication events. Versioned data (IdP / SP metadata, user attributes) remain. + $this->repository->deleteAuthenticationEventsOlderThan($dateTime); + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedState.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedState.php new file mode 100644 index 0000000000000000000000000000000000000000..6811a67f59fcccdd6eac391abf89ad0480de81c5 --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedState.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Services\HelpersManager; + +class HashDecoratedState +{ + protected State $state; + protected HelpersManager $helpersManager; + + protected string $identityProviderEntityIdHashSha256; + protected string $serviceProviderEntityIdHashSha256; + protected string $identityProviderMetadataArrayHashSha256; + protected string $serviceProviderMetadataArrayHashSha256; + protected string $attributesArrayHashSha256; + + public function __construct(State $state, HelpersManager $helpersManager = null) + { + $this->state = $state; + $this->helpersManager = $helpersManager ?? new HelpersManager(); + + $this->identityProviderEntityIdHashSha256 = $this->helpersManager->getHashHelper() + ->getSha256($state->getIdentityProviderEntityId()); + $this->identityProviderMetadataArrayHashSha256 = $this->helpersManager->getHashHelper() + ->getSha256ForArray($state->getIdentityProviderMetadata()); + + $this->serviceProviderEntityIdHashSha256 = $this->helpersManager->getHashHelper() + ->getSha256($state->getServiceProviderEntityId()); + $this->serviceProviderMetadataArrayHashSha256 = $this->helpersManager->getHashHelper() + ->getSha256ForArray($state->getServiceProviderMetadata()); + + $this->attributesArrayHashSha256 = $this->helpersManager->getHashHelper() + ->getSha256ForArray($state->getAttributes()); + } + + /** + * @return State + */ + public function getState(): State + { + return $this->state; + } + + /** + * @return string + */ + public function getIdentityProviderEntityIdHashSha256(): string + { + return $this->identityProviderEntityIdHashSha256; + } + + /** + * @return string + */ + public function getServiceProviderEntityIdHashSha256(): string + { + return $this->serviceProviderEntityIdHashSha256; + } + + /** + * @return string + */ + public function getIdentityProviderMetadataArrayHashSha256(): string + { + return $this->identityProviderMetadataArrayHashSha256; + } + + public function getServiceProviderMetadataArrayHashSha256(): string + { + return $this->serviceProviderMetadataArrayHashSha256; + } + + /** + * @return string + */ + public function getAttributesArrayHashSha256(): string + { + return $this->attributesArrayHashSha256; + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTable.php new file mode 100644 index 0000000000000000000000000000000000000000..66d8b08f2e4d861a0e5930cb5db719a08d58ec3e --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTable.php @@ -0,0 +1,69 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class Version20220801000000CreateIdpTable extends AbstractMigration +{ + protected function getLocalTablePrefix(): string + { + return 'vds_'; + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName('idp'); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('entity_id', Types::STRING) + ->setLength(TableConstants::COLUMN_ENTITY_ID_LENGTH); + + $table->addColumn('entity_id_hash_sha256', Types::STRING) + ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH) + ->setFixed(true); + + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $table->addUniqueConstraint(['entity_id_hash_sha256']); + + $this->schemaManager->createTable($table); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Error creating table \'%s.', $tableName), + $exception + ); + } + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName('idp'); + + try { + $this->schemaManager->dropTable($tableName); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTable.php new file mode 100644 index 0000000000000000000000000000000000000000..4c18cf2929b4ace28d84c16d3a81211904e7a383 --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTable.php @@ -0,0 +1,74 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class Version20220801000100CreateIdpVersionTable extends AbstractMigration +{ + protected function getLocalTablePrefix(): string + { + return 'vds_'; + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName('idp_version'); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('idp_id', Types::BIGINT) + ->setUnsigned(true); + + $table->addColumn('metadata', Types::TEXT); + + $table->addColumn('metadata_hash_sha256', Types::STRING) + ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH) + ->setFixed(true); + + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $table->addForeignKeyConstraint($this->preparePrefixedTableName('idp'), ['idp_id'], ['id']); + + $table->addUniqueConstraint(['metadata_hash_sha256']); + + $this->schemaManager->createTable($table); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Error creating table \'%s.', $tableName), + $exception + ); + } + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName('idp_version'); + + try { + $this->schemaManager->dropTable($tableName); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTable.php new file mode 100644 index 0000000000000000000000000000000000000000..b9418b6d1224d8597253b0f90c4e3ec06871bb3f --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTable.php @@ -0,0 +1,69 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class Version20220801000200CreateSpTable extends AbstractMigration +{ + protected function getLocalTablePrefix(): string + { + return 'vds_'; + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName('sp'); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('entity_id', Types::STRING) + ->setLength(TableConstants::COLUMN_ENTITY_ID_LENGTH); + + $table->addColumn('entity_id_hash_sha256', Types::STRING) + ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH) + ->setFixed(true); + + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $table->addUniqueConstraint(['entity_id_hash_sha256']); + + $this->schemaManager->createTable($table); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Error creating table \'%s.', $tableName), + $exception + ); + } + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName('sp'); + + try { + $this->schemaManager->dropTable($tableName); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTable.php new file mode 100644 index 0000000000000000000000000000000000000000..13a55358e0491d17099973de3f98ab9385c44136 --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTable.php @@ -0,0 +1,74 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class Version20220801000300CreateSpVersionTable extends AbstractMigration +{ + protected function getLocalTablePrefix(): string + { + return 'vds_'; + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName('sp_version'); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('sp_id', Types::BIGINT) + ->setUnsigned(true); + + $table->addColumn('metadata', Types::TEXT); + + $table->addColumn('metadata_hash_sha256', Types::STRING) + ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH) + ->setFixed(true); + + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $table->addForeignKeyConstraint($this->preparePrefixedTableName('sp'), ['sp_id'], ['id']); + + $table->addUniqueConstraint(['metadata_hash_sha256']); + + $this->schemaManager->createTable($table); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Error creating table \'%s.', $tableName), + $exception + ); + } + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName('sp_version'); + + try { + $this->schemaManager->dropTable($tableName); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTable.php new file mode 100644 index 0000000000000000000000000000000000000000..667d8ffa9e4d53a669005a49218d8b74ec9f98dd --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTable.php @@ -0,0 +1,70 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class Version20220801000400CreateUserTable extends AbstractMigration +{ + protected function getLocalTablePrefix(): string + { + return 'vds_'; + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName('user'); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('identifier', Types::TEXT) + ->setLength(65535); + + $table->addColumn('identifier_hash_sha256', Types::STRING) + ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH) + ->setFixed(true); + + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $table->addUniqueConstraint(['identifier_hash_sha256']); + + $this->schemaManager->createTable($table); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Error creating table \'%s.', $tableName), + $exception + ); + } + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName('user'); + + try { + $this->schemaManager->dropTable($tableName); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTable.php new file mode 100644 index 0000000000000000000000000000000000000000..fcb2b42ca0e72b02d561eb395aad33c1154cfe7c --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTable.php @@ -0,0 +1,75 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class Version20220801000500CreateUserVersionTable extends AbstractMigration +{ + protected function getLocalTablePrefix(): string + { + return 'vds_'; + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName('user_version'); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('user_id', Types::BIGINT) + ->setUnsigned(true); + + $table->addColumn('attributes', Types::TEXT) + ->setComment('Serialized attributes.'); + + $table->addColumn('attributes_hash_sha256', Types::STRING) + ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH) + ->setFixed(true); + + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $table->addForeignKeyConstraint($this->preparePrefixedTableName('user'), ['user_id'], ['id']); + + $table->addUniqueConstraint(['attributes_hash_sha256']); + + $this->schemaManager->createTable($table); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Error creating table \'%s.', $tableName), + $exception + ); + } + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName('user_version'); + + try { + $this->schemaManager->dropTable($tableName); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTable.php new file mode 100644 index 0000000000000000000000000000000000000000..625e6512f4c8f3f23d8c249e82d477e323054152 --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTable.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class Version20220801000600CreateIdpSpUserVersionTable extends AbstractMigration +{ + protected function getLocalTablePrefix(): string + { + return 'vds_'; + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName('idp_sp_user_version'); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('idp_version_id', Types::BIGINT) + ->setUnsigned(true); + + $table->addColumn('sp_version_id', Types::BIGINT) + ->setUnsigned(true); + + $table->addColumn('user_version_id', Types::BIGINT) + ->setUnsigned(true); + + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $table->addForeignKeyConstraint( + $this->preparePrefixedTableName('idp_version'), + ['idp_version_id'], + ['id'] + ); + + $table->addForeignKeyConstraint( + $this->preparePrefixedTableName('sp_version'), + ['sp_version_id'], + ['id'] + ); + + $table->addForeignKeyConstraint( + $this->preparePrefixedTableName('user_version'), + ['user_version_id'], + ['id'] + ); + + $table->addUniqueConstraint(['idp_version_id', 'sp_version_id', 'user_version_id']); + + $this->schemaManager->createTable($table); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Error creating table \'%s.', $tableName), + $exception + ); + } + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName('idp_sp_user_version'); + + try { + $this->schemaManager->dropTable($tableName); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTable.php new file mode 100644 index 0000000000000000000000000000000000000000..be8e304cd55d8db5d95093d454758b2f3503ae3b --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTable.php @@ -0,0 +1,79 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class Version20220801000700CreateAuthenticationEventTable extends AbstractMigration +{ + protected function getLocalTablePrefix(): string + { + return 'vds_'; + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName('authentication_event'); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('idp_sp_user_version_id', Types::BIGINT) + ->setUnsigned(true); + + $table->addColumn('happened_at', Types::DATETIMETZ_IMMUTABLE); + + $table->addColumn('client_ip_address', Types::STRING) + ->setLength(TableConstants::COLUMN_IP_ADDRESS_LENGTH) + ->setNotnull(false); + + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $table->addForeignKeyConstraint( + $this->preparePrefixedTableName('idp_sp_user_version'), + ['idp_sp_user_version_id'], + ['id'] + ); + + // Old data can be deleted using happened_at column, so add index for it. + $table->addIndex(['happened_at']); + + $this->schemaManager->createTable($table); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Error creating table \'%s.', $tableName), + $exception + ); + } + } + + /** + * @inheritDoc + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName('authentication_event'); + + try { + $this->schemaManager->dropTable($tableName); + } catch (\Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivity.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivity.php new file mode 100644 index 0000000000000000000000000000000000000000..17fb9e3528b47bd1e36b5a94cfabeda43328cf55 --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivity.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +use DateTimeImmutable; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity; + +class RawActivity extends AbstractRawEntity +{ + protected array $serviceProviderMetadata; + protected array $userAttributes; + protected DateTimeImmutable $happenedAt; + protected ?string $clientIpAddress; + + public function __construct(array $rawRow, AbstractPlatform $abstractPlatform) + { + parent::__construct($rawRow, $abstractPlatform); + + $this->serviceProviderMetadata = $this->resolveServiceProviderMetadata( + (string)$rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA] + ); + + $this->userAttributes = $this->resolveUserAttributes( + (string)$rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES] + ); + + $this->happenedAt = $this->resolveDateTimeImmutable( + $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT] + ); + + $this->clientIpAddress = empty($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_CLIENT_IP_ADDRESS]) ? + null : + (string)$rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_CLIENT_IP_ADDRESS]; + } + + /** + * @return DateTimeImmutable + */ + public function getHappenedAt(): DateTimeImmutable + { + return $this->happenedAt; + } + + /** + * @return array + */ + public function getServiceProviderMetadata(): array + { + return $this->serviceProviderMetadata; + } + + /** + * @return array + */ + public function getUserAttributes(): array + { + return $this->userAttributes; + } + + /** + * @return string|null + */ + public function getClientIpAddress(): ?string + { + return $this->clientIpAddress; + } + + /** + * @inheritDoc + */ + protected function validate(array $rawRow): void + { + $columnsToCheck = [ + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA, + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES, + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT, + ]; + + foreach ($columnsToCheck as $column) { + if (empty($rawRow[$column])) { + throw new UnexpectedValueException(sprintf('Column %s must be set.', $column)); + } + } + + if (! is_string($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA])) { + $message = sprintf( + 'Column %s must be string.', + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA + ); + throw new UnexpectedValueException($message); + } + + if (! is_string($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES])) { + $message = sprintf( + 'Column %s must be string.', + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES + ); + throw new UnexpectedValueException($message); + } + + if (! is_string($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT])) { + $message = sprintf( + 'Column %s must be string.', + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT + ); + throw new UnexpectedValueException($message); + } + } + + protected function resolveServiceProviderMetadata(string $serializedMetadata): array + { + /** @psalm-suppress MixedAssignment - we check the type manually */ + $metadata = unserialize($serializedMetadata); + + if (is_array($metadata)) { + return $metadata; + } + + $message = sprintf('Metadata not in expected array format, got type %s.', gettype($metadata)); + throw new UnexpectedValueException($message); + } + + protected function resolveUserAttributes(string $serializedUserAttributes): array + { + /** @psalm-suppress MixedAssignment - we check the type manually */ + $userAttributes = unserialize($serializedUserAttributes); + + if (is_array($userAttributes)) { + return $userAttributes; + } + + $message = sprintf('User attributes not in expected array format, got type %s.', gettype($userAttributes)); + throw new UnexpectedValueException($message); + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProvider.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..f9ed95a75e80ed431f60db63c2d3c8dba120af3c --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProvider.php @@ -0,0 +1,170 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +use DateTimeImmutable; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity; + +class RawConnectedServiceProvider extends AbstractRawEntity +{ + protected int $numberOfAuthentications; + protected DateTimeImmutable $lastAuthenticationAt; + protected DateTimeImmutable $firstAuthenticationAt; + protected array $serviceProviderMetadata; + protected array $userAttributes; + + public function __construct(array $rawRow, AbstractPlatform $abstractPlatform) + { + parent::__construct($rawRow, $abstractPlatform); + + $this->numberOfAuthentications = (int)$rawRow[ + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS + ]; + + $this->lastAuthenticationAt = $this->resolveDateTimeImmutable( + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT] + ); + + $this->firstAuthenticationAt = $this->resolveDateTimeImmutable( + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT] + ); + + $this->serviceProviderMetadata = $this->resolveServiceProviderMetadata( + (string)$rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] + ); + + $this->userAttributes = $this->resolveUserAttributes( + (string)$rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] + ); + } + + /** + * @return int + */ + public function getNumberOfAuthentications(): int + { + return $this->numberOfAuthentications; + } + + /** + * @return DateTimeImmutable + */ + public function getLastAuthenticationAt(): DateTimeImmutable + { + return $this->lastAuthenticationAt; + } + + /** + * @return DateTimeImmutable + */ + public function getFirstAuthenticationAt(): DateTimeImmutable + { + return $this->firstAuthenticationAt; + } + + /** + * @return array + */ + public function getServiceProviderMetadata(): array + { + return $this->serviceProviderMetadata; + } + + /** + * @return array + */ + public function getUserAttributes(): array + { + return $this->userAttributes; + } + + /** + * @inheritDoc + */ + protected function validate(array $rawRow): void + { + $columnsToCheck = [ + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS, + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT, + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT, + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA, + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES, + ]; + + foreach ($columnsToCheck as $column) { + if (empty($rawRow[$column])) { + throw new UnexpectedValueException(sprintf('Column %s must be set.', $column)); + } + } + + if ( + ! is_numeric($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS]) + ) { + $message = sprintf( + 'Column %s must be numeric.', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS + ); + throw new UnexpectedValueException($message); + } + + if (! is_string($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT])) { + $message = sprintf( + 'Column %s must be string.', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT + ); + throw new UnexpectedValueException($message); + } + + if (! is_string($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT])) { + $message = sprintf( + 'Column %s must be string.', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT + ); + throw new UnexpectedValueException($message); + } + + if (! is_string($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA])) { + $message = sprintf( + 'Column %s must be string.', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA + ); + throw new UnexpectedValueException($message); + } + + if (! is_string($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES])) { + $message = sprintf( + 'Column %s must be string.', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES + ); + throw new UnexpectedValueException($message); + } + } + + protected function resolveServiceProviderMetadata(string $serializedMetadata): array + { + /** @psalm-suppress MixedAssignment - we check the type manually */ + $metadata = unserialize($serializedMetadata); + + if (is_array($metadata)) { + return $metadata; + } + + $message = sprintf('Metadata not in expected array format, got type %s.', gettype($metadata)); + throw new UnexpectedValueException($message); + } + + protected function resolveUserAttributes(string $serializedUserAttributes): array + { + /** @psalm-suppress MixedAssignment - we check the type manually */ + $userAttributes = unserialize($serializedUserAttributes); + + if (is_array($userAttributes)) { + return $userAttributes; + } + + $message = sprintf('User attributes not in expected array format, got type %s.', gettype($userAttributes)); + throw new UnexpectedValueException($message); + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Repository.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Repository.php new file mode 100644 index 0000000000000000000000000000000000000000..24c9b34492e51555959e33a3e482736b1ea7bd22 --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Repository.php @@ -0,0 +1,1093 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Result; +use Doctrine\DBAL\Types\Types; +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use Throwable; + +class Repository +{ + protected Connection $connection; + protected LoggerInterface $logger; + protected string $tableNameIdp; + protected string $tableNameIdpVersion; + protected string $tableNameSp; + protected string $tableNameSpVersion; + protected string $tableNameUser; + protected string $tableNameUserVersion; + protected string $tableNameIdpSpUserVersion; + protected string $tableNameAuthenticationEvent; + + public function __construct(Connection $connection, LoggerInterface $logger) + { + $this->connection = $connection; + $this->logger = $logger; + + $this->tableNameIdp = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_IDP); + $this->tableNameIdpVersion = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_IDP_VERSION); + $this->tableNameSp = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_SP); + $this->tableNameSpVersion = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_SP_VERSION); + $this->tableNameUser = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_USER); + $this->tableNameUserVersion = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_USER_VERSION); + $this->tableNameIdpSpUserVersion = + $this->preparePrefixedTableName(TableConstants::TABLE_NAME_IDP_SP_USER_VERSION); + $this->tableNameAuthenticationEvent = + $this->preparePrefixedTableName(TableConstants::TABLE_NAME_AUTHENTICATION_EVENT); + } + + protected function preparePrefixedTableName(string $tableName): string + { + return $this->connection->preparePrefixedTableName(TableConstants::TABLE_PREFIX . $tableName); + } + + /** + * @throws StoreException + */ + public function getIdp(string $entityIdHashSha256): Result + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $queryBuilder->select( + TableConstants::TABLE_IDP_COLUMN_NAME_ID, + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID, + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256, + TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT, + ) + ->from($this->tableNameIdp) + ->where( + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 . ' = ' . + $queryBuilder->createNamedParameter($entityIdHashSha256) + )->setMaxResults(1); + + return $queryBuilder->executeQuery(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to get IdP by entity ID hash SHA256 \'%s\'. Error was: %s.', + $entityIdHashSha256, + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function insertIdp( + string $entityId, + string $entityIdHashSha256, + \DateTimeImmutable $createdAt = null + ): void { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $createdAt = $createdAt ?? new \DateTimeImmutable(); + + $queryBuilder->insert($this->tableNameIdp) + ->values( + [ + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID => ':' . + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID, + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => ':' . + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256, + TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT => ':' . + TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID => $entityId, + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => $entityIdHashSha256, + TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT => $createdAt, + ], + [ + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID => Types::STRING, + TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => Types::STRING, + TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE + ] + ); + + try { + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf('Error executing query to insert IdP. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getIdpVersion(int $idpId, string $metadataHashSha256): Result + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $queryBuilder->select( + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT, + ) + ->from($this->tableNameIdpVersion) + ->where( + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID, + $queryBuilder->createNamedParameter($idpId, ParameterType::INTEGER) + ), + $queryBuilder->expr()->eq( + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256, + $queryBuilder->createNamedParameter($metadataHashSha256) + ) + ) + )->setMaxResults(1); + + return $queryBuilder->executeQuery(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to get IdP Version for IdP %s and metadata array hash %s. Error was: %s.', + $idpId, + $metadataHashSha256, + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function insertIdpVersion( + int $idpId, + string $metadata, + string $metadataHashSha256, + \DateTimeImmutable $createdAt = null + ): void { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $createdAt = $createdAt ?? new \DateTimeImmutable(); + + $queryBuilder->insert($this->tableNameIdpVersion) + ->values( + [ + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID => ':' . + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA => ':' . + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => ':' . + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT => ':' . + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID => $idpId, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA => $metadata, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => $metadataHashSha256, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT => $createdAt, + ], + [ + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID => Types::BIGINT, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA => Types::TEXT, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => Types::STRING, + TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE, + ] + ); + + try { + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf('Error executing query to insert IdP Version. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getSp(string $entityIdHashSha256): Result + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $queryBuilder->select( + TableConstants::TABLE_SP_COLUMN_NAME_ID, + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID, + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256, + TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT, + ) + ->from($this->tableNameSp) + ->where( + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 . ' = ' . + $queryBuilder->createNamedParameter($entityIdHashSha256) + )->setMaxResults(1); + + return $queryBuilder->executeQuery(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to get SP by entity ID hash SHA256 \'%s\'. Error was: %s.', + $entityIdHashSha256, + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + public function insertSp( + string $entityId, + string $entityIdHashSha256, + \DateTimeImmutable $createdAt = null + ): void { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $createdAt = $createdAt ?? new \DateTimeImmutable(); + + $queryBuilder->insert($this->tableNameSp) + ->values( + [ + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID => ':' . + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID, + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => ':' . + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256, + TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT => ':' . + TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID => $entityId, + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => $entityIdHashSha256, + TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT => $createdAt, + ], + [ + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID => Types::STRING, + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => Types::STRING, + TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE + ] + ); + + try { + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf('Error executing query to insert SP. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getSpVersion(int $spId, string $metadataHashSha256): Result + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $queryBuilder->select( + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT, + ) + ->from($this->tableNameSpVersion) + ->where( + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID, + $queryBuilder->createNamedParameter($spId, ParameterType::INTEGER) + ), + $queryBuilder->expr()->eq( + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256, + $queryBuilder->createNamedParameter($metadataHashSha256) + ) + ) + )->setMaxResults(1); + + return $queryBuilder->executeQuery(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to get SP Version for SP %s and metadata array hash %s. Error was: %s.', + $spId, + $metadataHashSha256, + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function insertSpVersion( + int $spId, + string $metadata, + string $metadataHashSha256, + \DateTimeImmutable $createdAt = null + ): void { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $createdAt = $createdAt ?? new \DateTimeImmutable(); + + $queryBuilder->insert($this->tableNameSpVersion) + ->values( + [ + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID => ':' . + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA => ':' . + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => ':' . + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT => ':' . + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID => $spId, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA => $metadata, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => $metadataHashSha256, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT => $createdAt, + ], + [ + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID => Types::BIGINT, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA => Types::TEXT, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => Types::STRING, + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE, + ] + ); + + try { + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf('Error executing query to insert SP Version. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getUser(string $identifierHashSha256): Result + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $queryBuilder->select( + TableConstants::TABLE_USER_COLUMN_NAME_ID, + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER, + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256, + TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT, + ) + ->from($this->tableNameUser) + ->where( + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 . ' = ' . + $queryBuilder->createNamedParameter($identifierHashSha256) + )->setMaxResults(1); + + return $queryBuilder->executeQuery(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to get user by identifier hash SHA256 \'%s\'. Error was: %s.', + $identifierHashSha256, + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function insertUser( + string $identifier, + string $identifierHashSha256, + \DateTimeImmutable $createdAt = null + ): void { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $createdAt = $createdAt ?? new \DateTimeImmutable(); + + $queryBuilder->insert($this->tableNameUser) + ->values( + [ + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER => ':' . + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER, + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 => ':' . + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256, + TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT => ':' . + TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER => $identifier, + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 => $identifierHashSha256, + TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT => $createdAt, + ], + [ + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER => Types::TEXT, + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 => Types::STRING, + TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE + ] + ); + + try { + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf('Error executing query to insert user. Error was: %s.', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getUserVersion(int $userId, string $attributesHashSha256): Result + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $queryBuilder->select( + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT, + ) + ->from($this->tableNameUserVersion) + ->where( + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID, + $queryBuilder->createNamedParameter($userId, ParameterType::INTEGER) + ), + $queryBuilder->expr()->eq( + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256, + $queryBuilder->createNamedParameter($attributesHashSha256) + ) + ) + )->setMaxResults(1); + + return $queryBuilder->executeQuery(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to get user version for user ID %s and attribute array hash %s. Error was: %s.', + $userId, + $attributesHashSha256, + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function insertUserVersion( + int $userId, + string $attributes, + string $attributesHashSha256, + \DateTimeImmutable $createdAt = null + ): void { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $createdAt = $createdAt ?? new \DateTimeImmutable(); + + $queryBuilder->insert($this->tableNameUserVersion) + ->values( + [ + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID => ':' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES => ':' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256 => ':' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT => ':' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID => $userId, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES => $attributes, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256 => $attributesHashSha256, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT => $createdAt, + ], + [ + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID => Types::BIGINT, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES => Types::TEXT, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256 => Types::STRING, + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE, + ] + ); + + try { + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to insert user version. Error was: %s.', + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getIdpSpUserVersion(int $idpVersionId, int $spVersionId, int $userVersionId): Result + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $queryBuilder->select( + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT, + ) + ->from($this->tableNameIdpSpUserVersion) + ->where( + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID, + $queryBuilder->createNamedParameter($idpVersionId, ParameterType::INTEGER) + ), + $queryBuilder->expr()->eq( + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID, + $queryBuilder->createNamedParameter($spVersionId, ParameterType::INTEGER) + ), + $queryBuilder->expr()->eq( + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID, + $queryBuilder->createNamedParameter($userVersionId, ParameterType::INTEGER) + ) + ) + )->setMaxResults(1); + + return $queryBuilder->executeQuery(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to get IdpSpUserVersion for IdpVersion %s, SpVersion %s and UserVersion %s.' . + ' Error was: %s.', + $idpVersionId, + $spVersionId, + $userVersionId, + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function insertIdpSpUserVersion( + int $idpVersionId, + int $spVersionId, + int $userVersionId, + \DateTimeImmutable $createdAt = null + ): void { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $createdAt = $createdAt ?? new \DateTimeImmutable(); + + $queryBuilder->insert($this->tableNameIdpSpUserVersion) + ->values( + [ + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID => ':' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID => ':' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID => ':' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT => ':' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID => $idpVersionId, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID => $spVersionId, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID => $userVersionId, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT => $createdAt, + ], + [ + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID => Types::BIGINT, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID => Types::BIGINT, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID => Types::BIGINT, + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE + ] + ); + + try { + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to insert IdpSpUserVersion. Error was: %s.', + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function insertAuthenticationEvent( + int $IdpSpUserVersionId, + \DateTimeImmutable $happenedAt, + string $clientIpAddress = null, + \DateTimeImmutable $createdAt = null + ): void { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $createdAt = $createdAt ?? new \DateTimeImmutable(); + + $queryBuilder->insert($this->tableNameAuthenticationEvent) + ->values( + [ + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID => ':' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT => ':' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CLIENT_IP_ADDRESS => ':' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CLIENT_IP_ADDRESS, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT => ':' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID => + $IdpSpUserVersionId, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT => $happenedAt, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CLIENT_IP_ADDRESS => $clientIpAddress, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT => $createdAt, + ], + [ + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID => + Types::BIGINT, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT => + Types::DATETIMETZ_IMMUTABLE, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CLIENT_IP_ADDRESS => + Types::STRING, + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT => + Types::DATETIMETZ_IMMUTABLE, + ] + ); + + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to insert AuthenticationEvent. Error was: %s.', + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getConnectedServiceProviders(string $userIdentifierHashSha256): array + { + try { + $authenticationEventsQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $lastMetadataAndAttributesQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $authenticationEventsQueryBuilder->select( + //'vs.entity_id AS sp_entity_id', + TableConstants::TABLE_ALIAS_SP . '.' . + TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID . ' AS ' . + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_ENTITY_ID, + //'COUNT(vae.id) AS number_of_authentications', + 'COUNT(' . TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_ID . ') AS ' . + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS, + //'MAX(vae.happened_at) AS last_authentication_at', + 'MAX(' . TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT . ') AS ' . + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT, + //'MIN(vae.happened_at) AS first_authentication_at', + 'MIN(' . TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT . ') AS ' . + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT, + )->from($this->tableNameAuthenticationEvent, TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT) + ->leftJoin( + //'vae', + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT, + //'vds_idp_sp_user_version', + $this->tableNameIdpSpUserVersion, + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vae.idp_sp_user_version_id = visuv.id' + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vds_sp_version', + $this->tableNameSpVersion, + //'vsv', + TableConstants::TABLE_ALIAS_SP_VERSION, + //'visuv.sp_version_id = vsv.id' + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'vsv', + TableConstants::TABLE_ALIAS_SP_VERSION, + //'vds_sp', + $this->tableNameSp, + //'vs', + TableConstants::TABLE_ALIAS_SP, + //'vsv.sp_id = vs.id' + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID . ' = ' . + TableConstants::TABLE_ALIAS_SP . '.' . TableConstants::TABLE_SP_COLUMN_NAME_ID + ) + ->leftJoin( + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vds_user_version', + $this->tableNameUserVersion, + //'vuv', + TableConstants::TABLE_ALIAS_USER_VERSION, + //'visuv.user_version_id = vuv.id' + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'vuv', + TableConstants::TABLE_ALIAS_USER_VERSION, + //'vds_user', + $this->tableNameUser, + //'vu', + TableConstants::TABLE_ALIAS_USER, + //'vuv.user_id = vu.id' + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID . ' = ' . + TableConstants::TABLE_ALIAS_USER . '.' . TableConstants::TABLE_USER_COLUMN_NAME_ID + ) + ->where( + //'vu.identifier_hash_sha256 = ' . + TableConstants::TABLE_ALIAS_USER . '.' . + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 . ' = ' . + $authenticationEventsQueryBuilder->createNamedParameter($userIdentifierHashSha256) + ) + ->groupBy( + //'vs.id' + TableConstants::TABLE_ALIAS_SP . '.' . TableConstants::TABLE_SP_COLUMN_NAME_ID + ) + ->orderBy( + //'number_of_authentications', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS, + 'DESC' + ); + + + /** @psalm-suppress TooManyArguments */ + $lastMetadataAndAttributesQueryBuilder->select( + //'vs.entity_id AS sp_entity_id', + TableConstants::TABLE_ALIAS_SP . '.' . TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID . ' AS ' . + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_ENTITY_ID, + //'vsv.metadata AS sp_metadata', + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA . + ' AS ' . TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA, + //'vuv.attributes AS user_attributes', + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES . ' AS ' . + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES + // 'vsv.id AS sp_version_id', + // 'vuv.id AS user_version_id', + )->from( + //'vds_authentication_event', + $this->tableNameAuthenticationEvent, + //'vae' + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT + ) + ->leftJoin( + //'vae', + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT, + //'vds_idp_sp_user_version', + $this->tableNameIdpSpUserVersion, + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vae.idp_sp_user_version_id = visuv.id' + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vds_sp_version', + $this->tableNameSpVersion, + //'vsv', + TableConstants::TABLE_ALIAS_SP_VERSION, + //'visuv.sp_version_id = vsv.id' + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'vsv', + TableConstants::TABLE_ALIAS_SP_VERSION, + //'vds_sp', + $this->tableNameSp, + //'vs', + TableConstants::TABLE_ALIAS_SP, + //'vsv.sp_id = vs.id' + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID . ' = ' . TableConstants::TABLE_ALIAS_SP . '.' . + TableConstants::TABLE_SP_COLUMN_NAME_ID + ) + ->leftJoin( + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vds_user_version', + $this->tableNameUserVersion, + //'vuv', + TableConstants::TABLE_ALIAS_USER_VERSION, + //'visuv.user_version_id = vuv.id' + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'vuv', + TableConstants::TABLE_ALIAS_USER_VERSION, + //'vds_user', + $this->tableNameUser, + //'vu', + TableConstants::TABLE_ALIAS_USER, + //'vuv.user_id = vu.id' + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID . ' = ' . TableConstants::TABLE_ALIAS_USER . + '.' . TableConstants::TABLE_USER_COLUMN_NAME_ID + ) + ->leftJoin( + //'vsv', + TableConstants::TABLE_ALIAS_SP_VERSION, + //'vds_sp_version', + $this->tableNameSpVersion, + //'vsv2', + TableConstants::TABLE_ALIAS_SP_VERSION_2, // Another alias for self joining... + //'vsv.id = vsv2.id AND vsv.id < vsv2.id' // To be able to get latest one... + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID . ' = ' . TableConstants::TABLE_ALIAS_SP_VERSION_2 . + '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID . ' AND ' . + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID . + ' < ' . TableConstants::TABLE_ALIAS_SP_VERSION_2 . '.' . + TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'vuv', + TableConstants::TABLE_ALIAS_USER_VERSION, + //'vds_user_version', + $this->tableNameUserVersion, + //'vuv2', + TableConstants::TABLE_ALIAS_USER_VERSION_2, // Another alias for self joining... + //'vuv.id = vuv2.id AND vuv.id < vuv2.id' // To be able to get latest one... + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID . ' = ' . + TableConstants::TABLE_ALIAS_USER_VERSION_2 + . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID . ' AND ' . + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID . + ' < ' . TableConstants::TABLE_ALIAS_USER_VERSION_2 . '.' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID + ) + ->where( + //'vu.identifier_hash_sha256 = ' . + TableConstants::TABLE_ALIAS_USER . '.' . + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 . ' = ' . + $lastMetadataAndAttributesQueryBuilder->createNamedParameter($userIdentifierHashSha256) + ) + ->andWhere( + //'vsv2.id IS NULL' + TableConstants::TABLE_ALIAS_SP_VERSION_2 . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID + . ' IS NULL' + ) + ->andWhere( + //'vuv2.id IS NULL' + TableConstants::TABLE_ALIAS_USER_VERSION_2 . '.' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID . ' IS NULL' + ); + + $numberOfAuthentications = $authenticationEventsQueryBuilder->executeQuery()->fetchAllAssociativeIndexed(); + $lastMetadataAndAttributes = + $lastMetadataAndAttributesQueryBuilder->executeQuery()->fetchAllAssociativeIndexed(); + + return array_merge_recursive($numberOfAuthentications, $lastMetadataAndAttributes); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error executing query to get connected organizations. Error was: %s.', + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function getActivity(string $userIdentifierHashSha256, int $maxResults, int $firstResult): array + { + try { + $authenticationEventsQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** @psalm-suppress TooManyArguments */ + $authenticationEventsQueryBuilder->select( + //'vae.happened_at', + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT, + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CLIENT_IP_ADDRESS, + //'vsv.metadata AS sp_metadata', + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA . + ' AS ' . TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA, + //'vuv.attributes AS user_attributes' + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES . ' AS ' . + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES + )->from( + //'vds_authentication_event', 'vae' + $this->tableNameAuthenticationEvent, + //'vae' + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT + ) + ->leftJoin( + //'vae', + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT, + //'vds_idp_sp_user_version', + $this->tableNameIdpSpUserVersion, + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vae.idp_sp_user_version_id = visuv.id' + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vds_sp_version', + $this->tableNameSpVersion, + //'vsv', + TableConstants::TABLE_ALIAS_SP_VERSION, + //'visuv.sp_version_id = vsv.id' + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'vsv', + TableConstants::TABLE_ALIAS_SP_VERSION, + //'vds_sp', + $this->tableNameSp, + //'vs', + TableConstants::TABLE_ALIAS_SP, + //'vsv.sp_id = vs.id' + TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID . + ' = ' . TableConstants::TABLE_ALIAS_SP . '.' . TableConstants::TABLE_SP_COLUMN_NAME_ID + ) + ->leftJoin( + //'visuv', + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION, + //'vds_user_version', + $this->tableNameUserVersion, + //'vuv', + TableConstants::TABLE_ALIAS_USER_VERSION, + //'visuv.user_version_id = vuv.id' + TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' . + TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID . ' = ' . + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID + ) + ->leftJoin( + //'vuv', + TableConstants::TABLE_ALIAS_USER_VERSION, + //'vds_user', + $this->tableNameUser, + //'vu', + TableConstants::TABLE_ALIAS_USER, + //'vuv.user_id = vu.id' + TableConstants::TABLE_ALIAS_USER_VERSION . '.' . + TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID . ' = ' . TableConstants::TABLE_ALIAS_USER . + '.' . TableConstants::TABLE_USER_COLUMN_NAME_ID + ) + ->where( + //'vu.identifier_hash_sha256 = ' . + TableConstants::TABLE_ALIAS_USER . '.' . + TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 . ' = ' . + $authenticationEventsQueryBuilder->createNamedParameter($userIdentifierHashSha256) + ) + ->orderBy( + //'vae.id', + TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' . + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_ID, + 'DESC' + ) + ->setMaxResults($maxResults) + ->setFirstResult($firstResult); + + return $authenticationEventsQueryBuilder->executeQuery()->fetchAllAssociative(); + } catch (\Throwable $exception) { + $message = sprintf( + 'Error executing query to get connected organizations. Error was: %s.', + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @throws StoreException + */ + public function deleteAuthenticationEventsOlderThan(\DateTimeImmutable $dateTime): void + { + try { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $queryBuilder->delete($this->tableNameAuthenticationEvent) + ->where( + $queryBuilder->expr()->lt( + TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT, + $queryBuilder->createNamedParameter($dateTime, Types::DATETIME_IMMUTABLE) + ) + )->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf( + 'Error executing query to delete old authentication events. Error was: %s.', + $exception->getMessage() + ); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } +} diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/TableConstants.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/TableConstants.php new file mode 100644 index 0000000000000000000000000000000000000000..532a8c7325f7249d70cdfcc5c335db7206089fb7 --- /dev/null +++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/TableConstants.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +class TableConstants +{ + public const TABLE_PREFIX = 'vds_'; // versioned data store + + // Any SAML entity ID should have maximum 1024 chars per + // https://stackoverflow.com/questions/24196369/what-to-present-at-saml-entityid-url + public const COLUMN_ENTITY_ID_LENGTH = 1024; + public const COLUMN_HASH_SHA265_HEXITS_LENGTH = 64; + public const COLUMN_IP_ADDRESS_LENGTH = 45; + + + // Table 'idp' + public const TABLE_NAME_IDP = 'idp'; + public const TABLE_ALIAS_IDP = self::TABLE_PREFIX . 'i'; + public const TABLE_IDP_COLUMN_NAME_ID = 'id'; // int + public const TABLE_IDP_COLUMN_NAME_ENTITY_ID = 'entity_id'; // Entity ID value, string, varchar(1024) + public const TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 = 'entity_id_hash_sha256'; // ha256 hash hexits, char(64) + public const TABLE_IDP_COLUMN_NAME_CREATED_AT = 'created_at'; // First time IdP usage, datetime + + // Table 'idp_version' + public const TABLE_NAME_IDP_VERSION = 'idp_version'; + public const TABLE_ALIAS_IDP_VERSION = self::TABLE_PREFIX . 'iv'; + public const TABLE_IDP_VERSION_COLUMN_NAME_ID = 'id'; // int ID + public const TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID = 'idp_id'; // FK + public const TABLE_IDP_VERSION_COLUMN_NAME_METADATA = 'metadata'; // Serialized IdP metadata version + public const TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 = 'metadata_hash_sha256'; // Metadata sha256 hash + public const TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT = 'created_at'; + + // Table 'sp', same structure as in 'idp' + public const TABLE_NAME_SP = 'sp'; + public const TABLE_ALIAS_SP = self::TABLE_PREFIX . 's'; + public const TABLE_SP_COLUMN_NAME_ID = 'id'; + public const TABLE_SP_COLUMN_NAME_ENTITY_ID = 'entity_id'; + public const TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 = 'entity_id_hash_sha256'; + public const TABLE_SP_COLUMN_NAME_CREATED_AT = 'created_at'; + + // Table 'sp_version', same structure as in 'idp_version' + public const TABLE_NAME_SP_VERSION = 'sp_version'; + public const TABLE_ALIAS_SP_VERSION = self::TABLE_PREFIX . 'sv'; + public const TABLE_ALIAS_SP_VERSION_2 = self::TABLE_ALIAS_SP_VERSION . '_2'; + public const TABLE_SP_VERSION_COLUMN_NAME_ID = 'id'; + public const TABLE_SP_VERSION_COLUMN_NAME_SP_ID = 'sp_id'; + public const TABLE_SP_VERSION_COLUMN_NAME_METADATA = 'metadata'; + public const TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 = 'metadata_hash_sha256'; + public const TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT = 'created_at'; + + // Table 'user' + public const TABLE_NAME_USER = 'user'; + public const TABLE_ALIAS_USER = self::TABLE_PREFIX . 'u'; + public const TABLE_USER_COLUMN_NAME_ID = 'id'; // int + public const TABLE_USER_COLUMN_NAME_IDENTIFIER = 'identifier'; // text, varies... (can be ePTID, which is long XML). + public const TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 = 'identifier_hash_sha256'; + public const TABLE_USER_COLUMN_NAME_CREATED_AT = 'created_at'; + + // Table 'user_version' (versioned attributes) + public const TABLE_NAME_USER_VERSION = 'user_version'; + public const TABLE_ALIAS_USER_VERSION = self::TABLE_PREFIX . 'uv'; + public const TABLE_ALIAS_USER_VERSION_2 = self::TABLE_ALIAS_USER_VERSION . '_2'; + public const TABLE_USER_VERSION_COLUMN_NAME_ID = 'id'; // int ID + public const TABLE_USER_VERSION_COLUMN_NAME_USER_ID = 'user_id'; // FK + public const TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES = 'attributes'; // Serialized attributes version + public const TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256 = 'attributes_hash_sha256'; + public const TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT = 'created_at'; + + // Attribute versions released to SP version + public const TABLE_NAME_IDP_SP_USER_VERSION = 'idp_sp_user_version'; + public const TABLE_ALIAS_IDP_SP_USER_VERSION = self::TABLE_PREFIX . 'isuv'; + public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID = 'id'; + public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID = 'idp_version_id'; + public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID = 'sp_version_id'; + public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID = 'user_version_id'; + public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT = 'created_at'; + + // Table 'authentication_event'. + public const TABLE_NAME_AUTHENTICATION_EVENT = 'authentication_event'; + public const TABLE_ALIAS_AUTHENTICATION_EVENT = self::TABLE_PREFIX . 'ae'; + public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_ID = 'id'; + public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID = 'idp_sp_user_version_id'; + public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT = 'happened_at'; + public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CLIENT_IP_ADDRESS = 'client_ip_address'; + public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT = 'created_at'; + + // Entity 'ConnectedOrganization' (service provider) related. + public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_ENTITY_ID = 'sp_entity_id'; + public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS = 'number_of_authentications'; + public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT = 'last_authentication_at'; + public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT = 'first_authentication_at'; + public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA = 'sp_metadata'; + public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES = 'user_attributes'; + + // Entity 'Activity' related. + public const ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA = 'sp_metadata'; + public const ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES = 'user_attributes'; + public const ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT = 'happened_at'; + public const ENTITY_ACTIVITY_COLUMN_NAME_CLIENT_IP_ADDRESS = 'client_ip_address'; +} diff --git a/src/Stores/Interfaces/ConnectionInterface.php b/src/Stores/Interfaces/ConnectionInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..b61d3208388cd61c501b2df5d93530628f501f59 --- /dev/null +++ b/src/Stores/Interfaces/ConnectionInterface.php @@ -0,0 +1,9 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Interfaces; + +interface ConnectionInterface +{ +} diff --git a/src/Stores/Interfaces/DataStoreInterface.php b/src/Stores/Interfaces/DataStoreInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..6fefe9a744454ade4ae0ffecdcb4dd045589ea01 --- /dev/null +++ b/src/Stores/Interfaces/DataStoreInterface.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Interfaces; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; +use SimpleSAML\Module\accounting\Entities\Activity; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +interface DataStoreInterface extends StoreInterface +{ + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER + ): self; + + public function persist(Event $authenticationEvent): void; + + public function getConnectedOrganizations(string $userIdentifierHashSha256): ConnectedServiceProvider\Bag; + + public function getActivity(string $userIdentifierHashSha256, int $maxResults, int $firstResult): Activity\Bag; + + public function deleteDataOlderThan(\DateTimeImmutable $dateTime): void; +} diff --git a/src/Stores/Interfaces/JobsStoreInterface.php b/src/Stores/Interfaces/JobsStoreInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..43f9bdb28d86e40c6659ce63d1c78c1530600278 --- /dev/null +++ b/src/Stores/Interfaces/JobsStoreInterface.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Interfaces; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +interface JobsStoreInterface extends StoreInterface +{ + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null + ): self; + + /** + * Add job to queue + * @param JobInterface $job + * @return void + */ + public function enqueue(JobInterface $job): void; + + /** + * Get job from queue + * @param string $type Type of the job, typically FQ class name of job object. + * @return ?JobInterface + */ + public function dequeue(string $type): ?JobInterface; + + /** + * @param JobInterface $job + * @return void + */ + public function markFailedJob(JobInterface $job): void; +} diff --git a/src/Stores/Interfaces/MigrationInterface.php b/src/Stores/Interfaces/MigrationInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..ce7e523381d6e84b95720bc319baaa8e380cf12e --- /dev/null +++ b/src/Stores/Interfaces/MigrationInterface.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Interfaces; + +interface MigrationInterface +{ + /** + * Run migration forward. + * + * @return void + */ + public function run(): void; + + /** + * Run migration backward. + * + * @return void + */ + public function revert(): void; +} diff --git a/src/Stores/Interfaces/StoreInterface.php b/src/Stores/Interfaces/StoreInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..29b1bd0b9734eef04185e3781e4c67b08702e94b --- /dev/null +++ b/src/Stores/Interfaces/StoreInterface.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Interfaces; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface; +use SimpleSAML\Module\accounting\Interfaces\SetupableInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; + +interface StoreInterface extends BuildableUsingModuleConfigurationInterface, SetupableInterface +{ + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null + ): self; +} diff --git a/src/Stores/Jobs/DoctrineDbal/Store.php b/src/Stores/Jobs/DoctrineDbal/Store.php new file mode 100644 index 0000000000000000000000000000000000000000..86107ec9ae979f9f921ff39a47ec45cc32ea94ab --- /dev/null +++ b/src/Stores/Jobs/DoctrineDbal/Store.php @@ -0,0 +1,153 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory; +use SimpleSAML\Module\accounting\Stores\Interfaces\JobsStoreInterface; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\TableConstants; +use Throwable; + +class Store extends AbstractStore implements JobsStoreInterface +{ + protected string $prefixedTableNameJobs; + protected string $prefixedTableNameFailedJobs; + protected Repository $jobsRepository; + protected Repository $failedJobsRepository; + + /** + * @throws StoreException + */ + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER, + Factory $connectionFactory = null, + Repository $jobsRepository = null, + Repository $failedJobsRepository = null + ) { + parent::__construct($moduleConfiguration, $logger, $connectionKey, $connectionType, $connectionFactory); + + $this->prefixedTableNameJobs = $this->connection->preparePrefixedTableName(TableConstants::TABLE_NAME_JOB); + $this->prefixedTableNameFailedJobs = $this->connection + ->preparePrefixedTableName(TableConstants::TABLE_NAME_JOB_FAILED); + + $this->jobsRepository = $jobsRepository ?? + new Repository($this->connection, $this->prefixedTableNameJobs, $this->logger); + + $this->failedJobsRepository = $failedJobsRepository ?? + new Repository($this->connection, $this->prefixedTableNameFailedJobs, $this->logger); + } + + /** + * @throws StoreException + */ + public function enqueue(JobInterface $job): void + { + $this->jobsRepository->insert($job); + } + + /** + * @throws StoreException + */ + public function dequeue(string $type): ?JobInterface + { + /** @noinspection PhpUnusedLocalVariableInspection - psalm reports possibly undefined variable */ + $job = null; + $attempts = 0; + $maxDeleteAttempts = 3; + $this->connection->dbal()->getTransactionIsolation(); + + // Do the dequeue without using transactions, since the underlying database engine might not support it + // (for example, MyISAM engine in MySQL database). + try { + // Check if there are any jobs in the store... + while (($job = $this->jobsRepository->getNext($type)) !== null) { + // We have job instance. + $jobId = $job->getId(); + + if ($jobId === null) { + throw new UnexpectedValueException('Retrieved job does not contain ID.'); + } + + $attempts++; + + // Let's try to delete this job from the store, so it can't be fetched again. + if ($this->jobsRepository->delete($jobId) === false) { + // It seems that this job has already been deleted in the meantime. + // Check if this happened before. If threshold is reached, throw. + // Otherwise, try to get next job again. + $message = sprintf( + 'Job retrieval was successful, however it was deleted in the meantime. Attempt: %s', + $attempts + ); + $this->logger->warning($message, ['jobId' => $jobId]); + if ($attempts > $maxDeleteAttempts) { + throw new StoreException($message); + } + + continue; + } + + // We have found and dequeued a job, so finish with the search. + break; + } + } catch (Throwable $exception) { + throw new StoreException( + 'Error while trying to dequeue a job.', + (int)$exception->getCode(), + $exception + ); + } + + return $job; + } + + public function getPrefixedTableNameJobs(): string + { + return $this->prefixedTableNameJobs; + } + + public function getPrefixedTableNameFailedJobs(): string + { + return $this->prefixedTableNameFailedJobs; + } + + /** + * Build store instance. + * @param ModuleConfiguration $moduleConfiguration + * @param LoggerInterface $logger + * @param string|null $connectionKey + * @param string $connectionType + * @return self + * @throws StoreException + */ + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null + ): self { + return new self( + $moduleConfiguration, + $logger, + $connectionKey + ); + } + + /** + * @throws StoreException + */ + public function markFailedJob(JobInterface $job): void + { + $this->failedJobsRepository->insert($job); + } +} diff --git a/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Bases/AbstractCreateJobsTable.php b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Bases/AbstractCreateJobsTable.php new file mode 100644 index 0000000000000000000000000000000000000000..203afb8dfb7d2a0e09dbc3c2367f6da41032adee --- /dev/null +++ b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Bases/AbstractCreateJobsTable.php @@ -0,0 +1,60 @@ +<?php + +namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases; + +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\TableConstants; +use Throwable; + +abstract class AbstractCreateJobsTable extends AbstractMigration +{ + /** + * @throws MigrationException + */ + public function run(): void + { + $tableName = $this->preparePrefixedTableName($this->getJobsTableName()); + + try { + $table = new Table($tableName); + + $table->addColumn('id', Types::BIGINT) + ->setUnsigned(true) + ->setAutoincrement(true); + + $table->addColumn('type', Types::STRING) + ->setLength(TableConstants::COLUMN_TYPE_LENGTH); + + $table->addColumn('payload', Types::TEXT); + $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE); + + $table->setPrimaryKey(['id']); + + $this->schemaManager->createTable($table); + } catch (Throwable $exception) { + throw $this->prepareGenericMigrationException( + \sprintf('Could not create table %s.', $tableName), + $exception + ); + } + } + + /** + * @throws MigrationException + */ + public function revert(): void + { + $tableName = $this->preparePrefixedTableName($this->getJobsTableName()); + + try { + $this->schemaManager->dropTable($tableName); + } catch (Throwable $exception) { + throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception); + } + } + + abstract protected function getJobsTableName(): string; +} diff --git a/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTable.php b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTable.php new file mode 100644 index 0000000000000000000000000000000000000000..838b202bcb7403e608126e9c32f70fd62d4c79ef --- /dev/null +++ b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTable.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations; + +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; + +class Version20220601000000CreateJobTable extends Store\Migrations\Bases\AbstractCreateJobsTable +{ + protected function getJobsTableName(): string + { + return 'job'; + } +} diff --git a/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTable.php b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTable.php new file mode 100644 index 0000000000000000000000000000000000000000..9aa852dc9bdd08fb3813d87c539745090e4936a7 --- /dev/null +++ b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTable.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations; + +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; + +class Version20220601000100CreateJobFailedTable extends Store\Migrations\Bases\AbstractCreateJobsTable +{ + protected function getJobsTableName(): string + { + return 'job_failed'; + } +} diff --git a/src/Stores/Jobs/DoctrineDbal/Store/RawJob.php b/src/Stores/Jobs/DoctrineDbal/Store/RawJob.php new file mode 100644 index 0000000000000000000000000000000000000000..5737b9230750963ae0c7bad7514d97deb1429261 --- /dev/null +++ b/src/Stores/Jobs/DoctrineDbal/Store/RawJob.php @@ -0,0 +1,119 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; + +use DateTimeImmutable; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\Types; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use Throwable; + +use function sprintf; + +class RawJob extends AbstractRawEntity +{ + protected int $id; + protected AbstractPayload $payload; + protected string $type; + protected DateTimeImmutable $createdAt; + + public function __construct(array $rawRow, AbstractPlatform $abstractPlatform) + { + parent::__construct($rawRow, $abstractPlatform); + + $this->id = (int)$rawRow[Store\TableConstants::COLUMN_NAME_ID]; + $this->payload = $this->resolvePayload((string)$rawRow[Store\TableConstants::COLUMN_NAME_PAYLOAD]); + $this->type = (string)$rawRow[Store\TableConstants::COLUMN_NAME_TYPE]; + $this->createdAt = $this->resolveDateTimeImmutable($rawRow[Store\TableConstants::COLUMN_NAME_CREATED_AT]); + } + + protected function validate(array $rawRow): void + { + $columnsToCheck = [ + Store\TableConstants::COLUMN_NAME_ID, + Store\TableConstants::COLUMN_NAME_PAYLOAD, + Store\TableConstants::COLUMN_NAME_TYPE, + Store\TableConstants::COLUMN_NAME_CREATED_AT, + ]; + + foreach ($columnsToCheck as $column) { + if (empty($rawRow[$column])) { + throw new UnexpectedValueException(sprintf('Column %s must be set.', $column)); + } + } + + if (! is_numeric($rawRow[Store\TableConstants::COLUMN_NAME_ID])) { + throw new UnexpectedValueException( + sprintf('Column %s must be numeric.', Store\TableConstants::COLUMN_NAME_ID) + ); + } + + if (! is_string($rawRow[Store\TableConstants::COLUMN_NAME_PAYLOAD])) { + throw new UnexpectedValueException( + sprintf('Column %s must be string.', Store\TableConstants::COLUMN_NAME_PAYLOAD) + ); + } + + if (! is_string($rawRow[Store\TableConstants::COLUMN_NAME_TYPE])) { + throw new UnexpectedValueException( + sprintf('Column %s must be string.', Store\TableConstants::COLUMN_NAME_TYPE) + ); + } + + if (! is_string($rawRow[Store\TableConstants::COLUMN_NAME_CREATED_AT])) { + throw new UnexpectedValueException( + sprintf('Column %s must be string.', Store\TableConstants::COLUMN_NAME_CREATED_AT) + ); + } + } + + protected function resolvePayload(string $rawPayload): AbstractPayload + { + /** @psalm-suppress MixedAssignment - we check the type manually */ + $payload = unserialize($rawPayload); + + if ($payload instanceof AbstractPayload) { + return $payload; + } + + throw new UnexpectedValueException('Job payload is not instance of AbstractPayload.'); + } + + /** + * @return int + */ + public function getId(): int + { + return $this->id; + } + + /** + * @return AbstractPayload + */ + public function getPayload(): AbstractPayload + { + return $this->payload; + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return DateTimeImmutable + */ + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Stores/Jobs/DoctrineDbal/Store/Repository.php b/src/Stores/Jobs/DoctrineDbal/Store/Repository.php new file mode 100644 index 0000000000000000000000000000000000000000..ac983a76fa6e004411e65ebefdec50da5af939ee --- /dev/null +++ b/src/Stores/Jobs/DoctrineDbal/Store/Repository.php @@ -0,0 +1,194 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; + +use Doctrine\DBAL\Types\Types; +use Psr\Log\LoggerInterface; +use ReflectionClass; +use SimpleSAML\Module\accounting\Entities\GenericJob; +use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use Throwable; + +class Repository +{ + protected Connection $connection; + + protected array $validJobsTableNames = []; + + protected string $tableName; + protected LoggerInterface $logger; + + /** + * @throws StoreException + */ + public function __construct(Connection $connection, string $tableName, LoggerInterface $logger) + { + $this->connection = $connection; + + $this->prepareValidJobsTableNames(); + + $this->validateTableName($tableName); + + $this->tableName = $tableName; + $this->logger = $logger; + } + + protected function prepareValidJobsTableNames(): void + { + $this->validJobsTableNames[] = $this->connection + ->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB); + $this->validJobsTableNames[] = $this->connection + ->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB_FAILED); + } + + /** + * @throws StoreException + */ + public function insert(JobInterface $job): void + { + $this->validateType($job->getType()); + + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $queryBuilder->insert($this->tableName) + ->values( + [ + Store\TableConstants::COLUMN_NAME_PAYLOAD => ':' . Store\TableConstants::COLUMN_NAME_PAYLOAD, + Store\TableConstants::COLUMN_NAME_TYPE => ':' . Store\TableConstants::COLUMN_NAME_TYPE, + Store\TableConstants::COLUMN_NAME_CREATED_AT => ':' . Store\TableConstants::COLUMN_NAME_CREATED_AT, + ] + ) + ->setParameters( + [ + Store\TableConstants::COLUMN_NAME_PAYLOAD => serialize($job->getPayload()), + Store\TableConstants::COLUMN_NAME_TYPE => $job->getType(), + Store\TableConstants::COLUMN_NAME_CREATED_AT => $job->getCreatedAt(), + ], + [ + Store\TableConstants::COLUMN_NAME_PAYLOAD => Types::TEXT, + Store\TableConstants::COLUMN_NAME_TYPE => Types::STRING, + Store\TableConstants::COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE + ] + ); + + try { + $queryBuilder->executeStatement(); + } catch (Throwable $exception) { + $message = sprintf('Could not insert job (%s)', $exception->getMessage()); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @param string|null $type + * @return ?JobInterface + * @throws StoreException + */ + public function getNext(string $type = null): ?JobInterface + { + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + + /** + * @psalm-suppress TooManyArguments - providing array or null is deprecated + */ + $queryBuilder->select( + Store\TableConstants::COLUMN_NAME_ID, + Store\TableConstants::COLUMN_NAME_PAYLOAD, + Store\TableConstants::COLUMN_NAME_TYPE, + Store\TableConstants::COLUMN_NAME_CREATED_AT + ) + ->from($this->tableName) + ->orderBy(Store\TableConstants::COLUMN_NAME_ID) + ->setMaxResults(1); + + if ($type !== null) { + $queryBuilder->where( + Store\TableConstants::COLUMN_NAME_TYPE . ' = ' . $queryBuilder->createNamedParameter($type) + ); + } + + try { + $result = $queryBuilder->executeQuery(); + $row = $result->fetchAssociative(); + } catch (Throwable $exception) { + $message = 'Error while trying to execute query to get next available job.'; + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + if ($row === false) { + return null; + } + + try { + $rawJob = new RawJob($row, $this->connection->dbal()->getDatabasePlatform()); + $rawJobType = $rawJob->getType(); + + // Try to create a specific job type. Otherwise, create a generic one. + if (class_exists($rawJobType) && is_subclass_of($rawJobType, JobInterface::class)) { + $job = (new ReflectionClass($rawJobType)) + ->newInstance($rawJob->getPayload(), $rawJob->getId(), $rawJob->getCreatedAt()); + } else { + // No (valid) job type, so generic one will do... + $job = new GenericJob($rawJob->getPayload(), $rawJob->getId(), $rawJob->getCreatedAt()); + } + } catch (Throwable $exception) { + $message = 'Could not create a job instance.'; + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + return $job; + } + + /** + * @throws StoreException + */ + public function delete(int $id): bool + { + try { + $numberOfAffectedRows = (int)$this->connection->dbal() + ->delete( + $this->tableName, + [Store\TableConstants::COLUMN_NAME_ID => $id], + [Store\TableConstants::COLUMN_NAME_ID => Types::BIGINT] + ); + } catch (Throwable $exception) { + $message = sprintf('Error while trying to delete a job with ID %s.', $id); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + + if ($numberOfAffectedRows === 0) { + return false; + } + + return true; + } + + /** + * @throws StoreException + */ + protected function validateTableName(string $tableName): void + { + if (!in_array($tableName, $this->validJobsTableNames)) { + throw new StoreException( + sprintf('Table %s is not valid table for storing jobs.', $tableName) + ); + } + } + + /** + * @throws StoreException + */ + protected function validateType(string $type): void + { + if (mb_strlen($type) > Store\TableConstants::COLUMN_TYPE_LENGTH) { + throw new StoreException( + sprintf('String length for type column exceeds %s limit.', Store\TableConstants::COLUMN_TYPE_LENGTH) + ); + } + } +} diff --git a/src/Stores/Jobs/DoctrineDbal/Store/TableConstants.php b/src/Stores/Jobs/DoctrineDbal/Store/TableConstants.php new file mode 100644 index 0000000000000000000000000000000000000000..55f1779217f7d8467ac5e2cb775c6a9c60920c99 --- /dev/null +++ b/src/Stores/Jobs/DoctrineDbal/Store/TableConstants.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; + +class TableConstants +{ + public const TABLE_NAME_JOB = 'job'; + public const TABLE_NAME_JOB_FAILED = 'job_failed'; + + // Both tables have same columns. + public const COLUMN_NAME_ID = 'id'; + public const COLUMN_NAME_PAYLOAD = 'payload'; + public const COLUMN_NAME_TYPE = 'type'; + public const COLUMN_NAME_CREATED_AT = 'created_at'; + + public const COLUMN_TYPE_LENGTH = 1024; +} diff --git a/src/Stores/Jobs/PhpRedis/RedisStore.php b/src/Stores/Jobs/PhpRedis/RedisStore.php new file mode 100644 index 0000000000000000000000000000000000000000..20e6003b132f335fb3b78c26d606fe24c6372a44 --- /dev/null +++ b/src/Stores/Jobs/PhpRedis/RedisStore.php @@ -0,0 +1,190 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Stores\Jobs\PhpRedis; + +use Psr\Log\LoggerInterface; +use Redis; +use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Stores\Bases\AbstractStore; +use SimpleSAML\Module\accounting\Stores\Interfaces\JobsStoreInterface; +use Throwable; + +class RedisStore extends AbstractStore implements JobsStoreInterface +{ + public const LIST_KEY_JOB = 'job'; + public const LIST_KEY_JOB_FAILED = 'job_failed'; + + protected Redis $redis; + + /** + * @throws StoreException + */ + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER, + Redis $redis = null + ) { + parent::__construct($moduleConfiguration, $logger, $connectionKey, $connectionType); + $this->redis = $redis ?? new Redis(); + $connectionParameters = $this->getConnectionParameters(); + + try { + if (!$this->redis->isConnected()) { + $this->redis->connect( + (string)($connectionParameters['host'] ?? ''), + (int)($connectionParameters['port'] ?? 6379), + (float)($connectionParameters['connectTimeout'] ?? 0.0), + null, + (int)($connectionParameters['retryInterval'] ?? 0), + (int)($connectionParameters['readTimeout'] ?? 0), + ); + } + } catch (Throwable $exception) { + $message = sprintf('Error trying to connect to Redis DB. Error was: %s', $exception->getMessage()); + $this->logger->error($message); + throw new StoreException($message, (int) $exception->getCode(), $exception); + } + + try { + if (isset($connectionParameters['auth'])) { + $this->redis->auth($connectionParameters['auth']); + } + } catch (Throwable $exception) { + $message = sprintf('Error trying to set auth parameter for Redis. Error was: %s', $exception->getMessage()); + $this->logger->error($message); + throw new StoreException($message); + } + + try { + $this->redis->setOption(Redis::OPT_PREFIX, $connectionParameters['keyPrefix'] ?? 'ssp_accounting:'); + } catch (Throwable $exception) { + $message = sprintf('Could not set key prefix for Redis. Error was: %s', $exception->getMessage()); + $this->logger->error($message); + throw new StoreException($message, (int)$exception->getCode(), $exception); + } + } + + /** + * @inheritDoc + * @throws StoreException + */ + public function enqueue(JobInterface $job): void + { + try { + $listKey = $this->resolveListKeyForType(self::LIST_KEY_JOB, $job->getType()); + $this->redis->rPush($listKey, serialize($job)); + } catch (Throwable $exception) { + $message = sprintf('Could not add job to Redis list. Error was: %s', $exception->getMessage()); + $this->logger->error($message); + throw new StoreException($message); + } + } + + /** + * @inheritDoc + * @throws StoreException + */ + public function dequeue(string $type): ?JobInterface + { + try { + $listKey = $this->resolveListKeyForType(self::LIST_KEY_JOB, $type); + if (!is_string($serializedJob = $this->redis->lPop($listKey))) { + return null; + } + } catch (Throwable $exception) { + $message = sprintf('Could not pop job from Redis list. Error was: %s', $exception->getMessage()); + $this->logger->error($message); + throw new StoreException($message); + } + + /** @var JobInterface|false $job */ + $job = unserialize($serializedJob); + + if ($job instanceof JobInterface) { + return $job; + } + + $message = sprintf( + 'Could not deserialize job entry which was available in Redis. Entry was %s.', + $serializedJob + ); + $this->logger->error($message); + throw new StoreException($message); + } + + /** + * @inheritDoc + * @throws StoreException + */ + public function markFailedJob(JobInterface $job): void + { + try { + $listKey = $this->resolveListKeyForType(self::LIST_KEY_JOB_FAILED, $job->getType()); + $this->redis->rPush($listKey, serialize($job)); + } catch (Throwable $exception) { + $message = sprintf('Could not mark job as failed. Error was: %s', $exception->getMessage()); + $this->logger->error($message); + throw new StoreException($message); + } + } + + /** + * @throws StoreException + * @codeCoverageIgnore + */ + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null + ): self { + return new self( + $moduleConfiguration, + $logger, + $connectionKey + ); + } + + public function needsSetup(): bool + { + return false; + } + + public function runSetup(): void + { + // No need for setup. + } + + /** + * @return array + * @throws InvalidConfigurationException + */ + protected function getConnectionParameters(): array + { + $connectionParameters = $this->moduleConfiguration->getConnectionParameters($this->connectionKey); + + if (!isset($connectionParameters['host'])) { + $message = 'PhpRedis class Redis expects at least host option to be set, none given.'; + $this->logger->error($message); + throw new InvalidConfigurationException($message); + } + + return $connectionParameters; + } + + /** + * @param string $list For example, job, job_failed... + * @param string $jobType For example, FQ class name of the job instance + * @return string Key with hashed type to conserve chars. + */ + protected function resolveListKeyForType(string $list, string $jobType): string + { + return $list . ':' . sha1($jobType); + } +} diff --git a/src/Trackers/Authentication/DoctrineDbal/Versioned/Tracker.php b/src/Trackers/Authentication/DoctrineDbal/Versioned/Tracker.php new file mode 100644 index 0000000000000000000000000000000000000000..9c1971a0ebdd4fb1de97db1bc0631ab1904cff99 --- /dev/null +++ b/src/Trackers/Authentication/DoctrineDbal/Versioned/Tracker.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Activity; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; +use SimpleSAML\Module\accounting\Stores\Interfaces\DataStoreInterface; +use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface; + +class Tracker implements AuthenticationDataTrackerInterface, AuthenticationDataProviderInterface +{ + protected ModuleConfiguration $moduleConfiguration; + protected LoggerInterface $logger; + protected DataStoreInterface $dataStore; + protected HelpersManager $helpersManager; + + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER, + HelpersManager $helpersManager = null, + DataStoreInterface $dataStore = null + ) { + $this->moduleConfiguration = $moduleConfiguration; + $this->logger = $logger; + + $this->helpersManager = $helpersManager ?? new HelpersManager(); + + // Use provided store or initialize default store for this tracker. + $this->dataStore = $dataStore ?? + (new DataStoreBuilder($this->moduleConfiguration, $this->logger, $this->helpersManager)) + ->build( + Store::class, + $this->moduleConfiguration->getClassConnectionKey(self::class), + $connectionType + ); + } + + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER + ): self { + return new self($moduleConfiguration, $logger, $connectionType); + } + + public function process(Event $authenticationEvent): void + { + $this->dataStore->persist($authenticationEvent); + } + + public function needsSetup(): bool + { + return $this->dataStore->needsSetup(); + } + + public function runSetup(): void + { + if (! $this->needsSetup()) { + $this->logger->warning('Run setup called, however setup is not needed.'); + return; + } + + $this->dataStore->runSetup(); + } + + public function getConnectedServiceProviders(string $userIdentifier): ConnectedServiceProvider\Bag + { + $userIdentifierHashSha256 = $this->helpersManager->getHashHelper()->getSha256($userIdentifier); + return $this->dataStore->getConnectedOrganizations($userIdentifierHashSha256); + } + + public function getActivity(string $userIdentifier, int $maxResults, int $firstResult): Activity\Bag + { + $userIdentifierHashSha256 = $this->helpersManager->getHashHelper()->getSha256($userIdentifier); + return $this->dataStore->getActivity($userIdentifierHashSha256, $maxResults, $firstResult); + } + + public function enforceDataRetentionPolicy(\DateInterval $retentionPolicy): void + { + $dateTime = (new \DateTimeImmutable())->sub($retentionPolicy); + + if ($dateTime === false) { + // @codeCoverageIgnoreStart + $message = sprintf( + 'Could not create DateTime instance for data retention policy enforcement. Retention policy was: %s.', + var_export($retentionPolicy, true) + ); + $this->logger->error($message); + throw new InvalidConfigurationException($message); + // @codeCoverageIgnoreEnd + } + + $this->dataStore->deleteDataOlderThan($dateTime); + } +} diff --git a/src/Trackers/Builders/AuthenticationDataTrackerBuilder.php b/src/Trackers/Builders/AuthenticationDataTrackerBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..388338d13de848a44b020c25d3d9131ac541738a --- /dev/null +++ b/src/Trackers/Builders/AuthenticationDataTrackerBuilder.php @@ -0,0 +1,60 @@ +<?php + +namespace SimpleSAML\Module\accounting\Trackers\Builders; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface; +use Throwable; + +class AuthenticationDataTrackerBuilder +{ + protected ModuleConfiguration $moduleConfiguration; + protected LoggerInterface $logger; + protected HelpersManager $helpersManager; + + public function __construct( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + HelpersManager $helpersManager + ) { + $this->moduleConfiguration = $moduleConfiguration; + $this->logger = $logger; + $this->helpersManager = $helpersManager; + } + + /** + * @throws Exception + */ + public function build(string $class): AuthenticationDataTrackerInterface + { + try { + // Make sure that the class implements proper interface + if (!is_subclass_of($class, AuthenticationDataTrackerInterface::class)) { + $message = sprintf( + 'Class %s does not implement interface %s.', + $class, + AuthenticationDataTrackerInterface::class + ); + throw new UnexpectedValueException($message); + } + + // Build... + /** @var AuthenticationDataTrackerInterface $store */ + $store = $this->helpersManager->getInstanceBuilderUsingModuleConfigurationHelper()->build( + $class, + $this->moduleConfiguration, + $this->logger + ); + } catch (Throwable $exception) { + $message = sprintf('Error building instance for class %s. Error was: %s', $class, $exception->getMessage()); + throw new Exception($message, (int)$exception->getCode(), $exception); + } + + return $store; + } +} diff --git a/src/Trackers/Interfaces/AuthenticationDataTrackerInterface.php b/src/Trackers/Interfaces/AuthenticationDataTrackerInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..9a1f826bd2420a0701b2289de541c2c89dddfd73 --- /dev/null +++ b/src/Trackers/Interfaces/AuthenticationDataTrackerInterface.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\accounting\Trackers\Interfaces; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface; +use SimpleSAML\Module\accounting\Interfaces\SetupableInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface; + +interface AuthenticationDataTrackerInterface extends BuildableUsingModuleConfigurationInterface, SetupableInterface +{ + public static function build(ModuleConfiguration $moduleConfiguration, LoggerInterface $logger): self; + + public function process(Event $authenticationEvent): void; + + public function enforceDataRetentionPolicy(\DateInterval $retentionPolicy): void; +} diff --git a/templates/admin/configuration/status.twig b/templates/admin/configuration/status.twig new file mode 100644 index 0000000000000000000000000000000000000000..9927f73ac568730751181d03c3fd9075b326c5b5 --- /dev/null +++ b/templates/admin/configuration/status.twig @@ -0,0 +1,77 @@ +{# @var moduleConfiguration \SimpleSAML\Module\accounting\ModuleConfiguration #} + +{% set pagetitle = 'Configuration Status'|trans %} +{% set frontpage_section = 'main' %} + +{% extends "base.twig" %} + +{% block content %} + + <h2>{{ pagetitle }} </h2> + + {% if configurationValidationErrors is not null %} + <p>{{ configurationValidationErrors }}</p> + {% elseif moduleConfiguration is not null %} + <ul> + <li> + <strong>{{ 'User ID Attribute Name'|trans }}</strong>: {{ moduleConfiguration.getUserIdAttributeName }} + </li> + <li> + <strong>{{ 'Accounting Processing Type'|trans }}</strong>: + {{ moduleConfiguration.getAccountingProcessingType }} + </li> + <li> + <strong>{{ 'Default Data Tracker and Provider Class'|trans }}</strong>: + {{ moduleConfiguration.getDefaultDataTrackerAndProviderClass }} + </li> + <li> + <strong>{{ 'Tracker and Provider Setup Needed'|trans }}</strong>: + {{ defaultDataTrackerAndProvider.needsSetup ? 'Yes'|trans : 'No'|trans }} + </li> + {% if additionalTrackers is not empty %} + <li> + <strong>{{ 'Additional Trackers and setup'|trans }}</strong>: + <ul> + {% for trackerClass, trackerInstance in additionalTrackers %} + <li> + {{ trackerClass }}: {{ trackerInstance.needsSetup ? 'Yes'|trans : 'No'|trans }} + </li> + {% endfor %} + </ul> + </li> + {% endif %} + </ul> + + {% if moduleConfiguration.getAccountingProcessingType == 'asynchronous' %} + <ul> + <li> + <strong>{{ 'Jobs Store Class'|trans }}</strong>: {{ moduleConfiguration.getJobsStoreClass }} + </li> + <li> + {% if jobsStore is not null %} + <strong>{{ 'Jobs Store Setup Needed'|trans }}</strong>: + {{ jobsStore.needsSetup ? 'Yes'|trans : 'No'|trans }} + {% else %} + {{ 'Could not initialize jobs store.'|trans }} + {% endif %} + </li> + </ul> + {% endif %} + {% else %} + <p>{{ 'Could not initialize module configuration.'|trans }}</p> + {% endif %} + + <br> + + {% if setupNeeded %} + <p>{{ 'Run setup before using the module.'|trans }}</p> + <a class="pure-button pure-button-warning" href="?runSetup=1">{{ 'Run Setup'|trans }}</a> + {% else %} + <p> + {{ 'Everything seems good to go.'|trans }} + <br> + <br> + {{ ' Profile page URL is'|trans }}: <a href="{{ profilePageUri }}">{{ profilePageUri }}</a> + </p> + {% endif %} +{% endblock %} diff --git a/templates/base.twig b/templates/base.twig new file mode 100644 index 0000000000000000000000000000000000000000..7465451f1977531904be1dba6867d691b53c2e09 --- /dev/null +++ b/templates/base.twig @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<html lang="{{ currentLanguage }}"> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> +{# <meta name="viewport" content="initial-scale=1.0">#} +{# <meta http-equiv="X-UA-Compatible" content="IE=Edge">#} + <title>{{ pagetitle }}</title> + <link rel="stylesheet" href="{{ asset('css/src/default.css', 'accounting') }}"> + <link rel="icon" href="{{ asset("icons/favicon.ico") }}"> + <meta name="robots" content="noindex, nofollow"> +</head> +<body id="{{ templateId }}"> + +{% include '@accounting/includes/_header.twig' %} + +{% include '@accounting/includes/_navigation.twig' %} + +{% block banner %}{% endblock %} + +<section id="main"> + {% block content %}{% endblock %} +</section> + +<footer> + <div> + {% trans %} + As part of the GÉANT 2020 Framework Partnership Agreement (FPA), the project receives funding from the European + Union’s Horizon 2020 research and innovation programme under Grant Agreement No. 856726 (GN4-3). + {% endtrans %} + </div> +</footer> +</body> +</html> \ No newline at end of file diff --git a/templates/includes/_header.twig b/templates/includes/_header.twig new file mode 100644 index 0000000000000000000000000000000000000000..5ba07d95bf4d71b3f885c9a5f09083d56ea41033 --- /dev/null +++ b/templates/includes/_header.twig @@ -0,0 +1,6 @@ +<header> + <div id="logo"><img src="{{ asset('css/src/icons/fppp-logo.svg', 'accounting') }}" alt="Profile Page Logo"/></div> + <label for="nav-toggle" class="nav-toggle-label"> + <span></span> + </label> +</header> \ No newline at end of file diff --git a/templates/includes/_navigation.twig b/templates/includes/_navigation.twig new file mode 100644 index 0000000000000000000000000000000000000000..a272262c7ea3d6e8dc21f5d112086842d836a250 --- /dev/null +++ b/templates/includes/_navigation.twig @@ -0,0 +1,40 @@ +<div id="nav"> + <input type="checkbox" id="nav-toggle" class="nav-toggle"> + <nav role="navigation" aria-label="XXX-pagedesc-XXX"> + <ul> + <li> + <a href="personal-data"> + <span class="navicon"> + <img src="{{ asset('css/src/icons/prof-page.svg', 'accounting') }}" alt="Profile Page Icon"/> + </span> + <span>{{ 'Personal Data'|trans }}</span> + </a> + </li> + <li> + <a href="connected-organizations"> + <span class="navicon"> + <img src="{{ asset('css/src/icons/conn-orgs.svg', 'accounting') }}" + alt="Connected Organizations Icon"/> + </span> + <span>{{ 'Connected Organizations'|trans }}</span> + </a> + </li> + <li> + <a href="activity"> + <span class="navicon"> + <img src="{{ asset('css/src/icons/activity.svg', 'accounting') }}" alt="Activity Icon"/> + </span> + <span>{{ 'Activity'|trans }}</span> + </a> + </li> + <li> + <a href="logout"> + <span class="navicon"> + <img src="{{ asset('css/src/icons/logout.svg', 'accounting') }}" alt="Logout Icon"/> + </span> + <span>{{ 'Log out'|trans }}</span> + </a> + </li> + </ul> + </nav> +</div> \ No newline at end of file diff --git a/templates/user/activity.twig b/templates/user/activity.twig new file mode 100644 index 0000000000000000000000000000000000000000..514feebdbd0517893fd6f4b7c2f895f4ce3f782f --- /dev/null +++ b/templates/user/activity.twig @@ -0,0 +1,68 @@ +{# @var activityBag \SimpleSAML\Module\accounting\Entities\Activity\Bag #} +{# @var connectedServiceProvider \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider #} + +{% extends "@accounting/base.twig" %} + +{% set pagetitle = 'Activity'|trans %} + +{% set pageMenuItem = 'activity' %} + +{% block content %} + <table> + <tr> + <th><strong>{{ 'Time'|trans }}</strong></th> + <th><strong>{{ 'Access'|trans }}</strong></th> + <th><strong>{{ 'Sent data'|trans }}</strong></th> + </tr> + + {% for activity in activityBag.getAll %} + <tr class="accordion"> + <td>{{ activity.getHappenedAt|date() }}</td> + <td>{{ activity.getServiceProvider.getName }}</td> + <td> + {% for name in activity.getUser.getAttributes|keys %} + {{ name|trans }}{% if not loop.last %}, {% endif %} + {% endfor %} + </td> + </tr> + <tr> + <td class="dropdown-container" colspan="3"> + <input type="checkbox" id="dropdown-toggle-{{ loop.index }}" class="dropdown-toggle"> + <label class="dropdown-label" for="dropdown-toggle-{{ loop.index }}"> + <img src="{{ asset('css/src/icons/dropdown.svg', 'accounting') }}" alt="Dropdown icon"> + </label> + <div class="dropdown-box"> + <strong>{{ 'IP address'|trans }}</strong> + <ul><li>{{ activity.getClientIpAddress|default(' / ') }}</li></ul> + + <strong>{{ 'Information transfered to service'|trans }}</strong> + <ul> + {% for name, value in activity.getUser.getAttributes %} + <li> + {{ name|trans }}: {{ value|join(', ') }} + </li> + {% endfor %} + </ul> + </div> + </td> + </tr> + {% else %} + <tr> + <td colspan="3">{{ 'No data available'|trans }}</td> + </tr> + {% endfor %} + </table> + <br> + <div class="center"> + <div class="pagination"> + {% if page > 1 %} + <a href="?page={{ page - 1 }}">❮</a> + {% endif %} + + {% if activityBag.getAll|length == maxResults %} + <a href="?page={{ page + 1 }}">❯</a> + {% endif %} + </div> + </div> + +{% endblock %} diff --git a/templates/user/activity.twig.bak b/templates/user/activity.twig.bak new file mode 100644 index 0000000000000000000000000000000000000000..5f77015382f7be5ffaa6e3ff22e554ad33ffd11a --- /dev/null +++ b/templates/user/activity.twig.bak @@ -0,0 +1,73 @@ +{# @var activityBag \SimpleSAML\Module\accounting\Entities\Activity\Bag #} +{# @var connectedServiceProvider \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider #} + +{% extends "@accounting/base.twig" %} + +{% set pagetitle = 'Activity'|trans %} + +{% set pageMenuItem = 'activity' %} + +{% block content %} + + <h2>{{ pagetitle }} </h2> + + <table class="pure-table pure-table-striped pure-table-attributes"> + <thead> + <tr> + <th><strong>{{ 'Time'|trans }}</strong></th> + <th><strong>{{ 'Access'|trans }}</strong></th> + <th><strong>{{ 'Sent data'|trans }}</strong></th> + </tr> + </thead> + <tbody> + {% for activity in activityBag.getAll %} + <tr class="accordion"> + <td>{{ activity.getHappenedAt|date() }}</td> + <td>{{ activity.getServiceProvider.getName }}</td> + <td> + {% for name in activity.getUser.getAttributes|keys %} + {{ name|trans }}{% if not loop.last %}, {% endif %} + {% endfor %} + </td> + </tr> + <tr class="panel"> + <td colspan="3"> + <strong>{{ 'IP address'|trans }}</strong> + <ul><li>{{ activity.getClientIpAddress }}</li></ul> + + <strong>{{ 'Information transfered to service'|trans }}</strong> + <ul> + {% for name, value in activity.getUser.getAttributes %} + <li> + {{ name|trans }}: {{ value|join(', ') }} + </li> + {% endfor %} + </ul> + </td> + </tr> + {% else %} + <tr> + <td colspan="3">{{ 'No data available'|trans }}</td> + </tr> + {% endfor %} + </tbody> + </table> + <br> + <div class="center"> + <div class="pagination"> + {% if page > 1 %} + <a href="?page={{ page - 1 }}">❮</a> + {% endif %} + + {% if activityBag.getAll|length == maxResults %} + <a href="?page={{ page + 1 }}">❯</a> + {% endif %} + </div> + </div> + +{% endblock %} + +{% block postload %} + {{ parent() }} + {% include '@accounting/includes/_js-accordion.twig' %} +{% endblock %} \ No newline at end of file diff --git a/templates/user/connected-organizations.twig b/templates/user/connected-organizations.twig new file mode 100644 index 0000000000000000000000000000000000000000..8bbe788587202edc09543178c7a689c414c00751 --- /dev/null +++ b/templates/user/connected-organizations.twig @@ -0,0 +1,66 @@ +{# @var connectedServiceProviderBag \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag #} +{# @var connectedServiceProvider \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider #} + +{% extends "@accounting/base.twig" %} + +{% set pagetitle = 'Connected Organizations'|trans %} + +{% set pageMenuItem = 'connected-organizations' %} + +{% block content %} + <table> + <!-- fixed table header --> + <tr> + <th><strong>{{ 'Name'|trans }}</strong></th> + <th><strong>{{ 'All access'|trans }}</strong></th> + <th><strong>{{ 'Last access'|trans }}</strong></th> + </tr> + + {% for connectedServiceProvider in connectedServiceProviderBag.getAll %} + <tr> + <td>{{ connectedServiceProvider.getServiceProvider.getName|e }}</td> + <td>{{ connectedServiceProvider.getNumberOfAuthentications|e }}</td> + <td>{{ connectedServiceProvider.getLastAuthenticationAt|date() }}</td> + </tr> + <tr> + <td class="dropdown-container" colspan="3"> + <input type="checkbox" id="dropdown-toggle-{{ loop.index }}" class="dropdown-toggle"> + <label class="dropdown-label" for="dropdown-toggle-{{ loop.index }}"> + <img src="{{ asset('css/src/icons/dropdown.svg', 'accounting') }}" alt="Dropdown icon"> + </label> + <div class="dropdown-box"> + <strong>{{ 'Service details'|trans }}</strong> + <ul> + <li>{{ 'Entity ID'|trans }}: {{ connectedServiceProvider.getServiceProvider.getEntityId|e }}</li> + <li>{{ 'Description'|trans }}: {{ connectedServiceProvider.getServiceProvider.getDescription|e|default(' / ') }}</li> + </ul> + + <strong>{{ 'Information transfered to service'|trans }}</strong> + <ul> + {% for name, value in connectedServiceProvider.getUser.getAttributes %} + <li> + {{ name|trans }}: {{ value|join(', ') }} + </li> + {% endfor %} + </ul> + + <strong>{{ 'Login details'|trans }}</strong> + <ul> + <li> + {{ 'First access'|trans }}: {{ connectedServiceProvider.getFirstAuthenticationAt|date() }} + </li> + <li> + {{ 'Last access'|trans }}: {{ connectedServiceProvider.getLastAuthenticationAt|date() }} + </li> + </ul> + </div> + </td> + </tr> + {% else %} + <tr> + <td colspan="3">{{ 'No data available'|trans }}</td> + </tr> + {% endfor %} + <!-- end of repeating item --> + </table> +{% endblock %} diff --git a/templates/user/connected-organizations.twig.bak b/templates/user/connected-organizations.twig.bak new file mode 100644 index 0000000000000000000000000000000000000000..0d5a57849814c6b3e21e6f52c19dda0250ca6395 --- /dev/null +++ b/templates/user/connected-organizations.twig.bak @@ -0,0 +1,70 @@ +{# @var connectedServiceProviderBag \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag #} +{# @var connectedServiceProvider \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider #} + +{% extends "@accounting/base.twig" %} + +{% set pagetitle = 'Connected organizations'|trans %} + +{% set pageMenuItem = 'connected-organizations' %} + +{% block content %} + + <h2>{{ pagetitle }} </h2> + + <table class="pure-table pure-table-striped pure-table-attributes"> + <thead> + <tr> + <th><strong>{{ 'Name'|trans }}</strong></th> + <th><strong>{{ 'All access'|trans }}</strong></th> + <th><strong>{{ 'Last access'|trans }}</strong></th> + </tr> + </thead> + <tbody> + {% for connectedServiceProvider in connectedServiceProviderBag.getAll %} + <tr class="accordion"> + <td>{{ connectedServiceProvider.getServiceProvider.getName|e }}</td> + <td>{{ connectedServiceProvider.getNumberOfAuthentications|e }}</td> + <td>{{ connectedServiceProvider.getLastAuthenticationAt|date() }}</td> + </tr> + <tr class="panel"> + <td colspan="3"> + <strong>{{ 'Service details'|trans }}</strong> + <ul> + <li>{{ 'Entity ID'|trans }}: {{ connectedServiceProvider.getServiceProvider.getEntityId|e }}</li> + <li>{{ 'Description'|trans }}: {{ connectedServiceProvider.getServiceProvider.getDescription|e|default(' / ') }}</li> + </ul> + + <strong>{{ 'Information transfered to service'|trans }}</strong> + <ul> + {% for name, value in connectedServiceProvider.getUser.getAttributes %} + <li> + {{ name|trans }}: {{ value|join(', ') }} + </li> + {% endfor %} + </ul> + + <strong>{{ 'Login details'|trans }}</strong> + <ul> + <li> + {{ 'First access'|trans }}: {{ connectedServiceProvider.getFirstAuthenticationAt|date() }} + </li> + <li> + {{ 'Last access'|trans }}: {{ connectedServiceProvider.getLastAuthenticationAt|date() }} + </li> + </ul> + </td> + </tr> + {% else %} + <tr> + <td colspan="3">{{ 'No data available'|trans }}</td> + </tr> + {% endfor %} + </tbody> + </table> + +{% endblock %} + +{% block postload %} + {{ parent() }} + {% include '@accounting/includes/_js-accordion.twig' %} +{% endblock %} diff --git a/templates/user/personal-data.twig b/templates/user/personal-data.twig new file mode 100644 index 0000000000000000000000000000000000000000..ee0e606a93164c77324428ce289568beb2229d33 --- /dev/null +++ b/templates/user/personal-data.twig @@ -0,0 +1,34 @@ + +{% extends "@accounting/base.twig" %} + +{% set pagetitle = 'Personal Data'|trans %} + +{% set pageMenuItem = 'personal-data' %} + +{% block banner %} + <section id="banner"> + <div> + {% trans %}This is what we know about you...{% endtrans %} + </div> + </section> +{% endblock %} + +{% block content %} + <table> + <!-- fixed table header --> + <tr> + <th>{{ 'Attribute'|trans }}</th> + <th>{{ 'Your value'|trans }}</th> + </tr> + + {% for name, value in normalizedAttributes %} + <tr> + <td> + {{ name|trans }} + <a href="#"><img src="{{ asset('css/src/icons/i.svg', 'accounting') }}" alt="Info icon"></a> + </td> + <td>{{ value }}</td> + </tr> + {% endfor %} + </table> +{% endblock %} diff --git a/templates/user/personal-data.twig.bak b/templates/user/personal-data.twig.bak new file mode 100644 index 0000000000000000000000000000000000000000..9aa3bc58c3bf220b2adf045627348591fdb1de80 --- /dev/null +++ b/templates/user/personal-data.twig.bak @@ -0,0 +1,35 @@ + +{% extends "@accounting/base.twig" %} + +{% set pagetitle = 'Personal Data'|trans %} + +{% set pageMenuItem = 'personal-data' %} + +{% block content %} + + <h2>{{ pagetitle }} </h2> + + <section id="banner"> + <div> + {% trans %}This is what we know about you...{% endtrans %} + </div> + </section> + + <table class="pure-table pure-table-striped pure-table-attributes"> + <thead> + <tr> + <th><strong>Attribute</strong></th> + <th><strong>Your value</strong></th> + </tr> + </thead> + <tbody> + {% for name, value in normalizedAttributes %} + <tr> + <td>{{ name|trans }}</td> + <td>{{ value }}</td> + </tr> + {% endfor %} + </tbody> + </table> + +{% endblock %} diff --git a/tests/attributemap/test.php b/tests/attributemap/test.php new file mode 100644 index 0000000000000000000000000000000000000000..3b756fa4f05d213e126c8b333628230cfd30a452 --- /dev/null +++ b/tests/attributemap/test.php @@ -0,0 +1,8 @@ +<?php +// phpcs:ignoreFile + +declare(strict_types=1); + +$attributemap = [ + 'mobile' => 'urn:mace:dir:attribute-def:mobile' +]; diff --git a/tests/attributemap/test2.php b/tests/attributemap/test2.php new file mode 100644 index 0000000000000000000000000000000000000000..6d4443e7b0c97d2244356b8a562491bb1b836561 --- /dev/null +++ b/tests/attributemap/test2.php @@ -0,0 +1,8 @@ +<?php +// phpcs:ignoreFile + +declare(strict_types=1); + +$attributemap = [ + 'phone' => 'urn:mace:dir:attribute-def:phone' +]; diff --git a/tests/config-templates/config.php b/tests/config-templates/config.php new file mode 100644 index 0000000000000000000000000000000000000000..a6c0417abcb66585642837aa25834dd82109c006 --- /dev/null +++ b/tests/config-templates/config.php @@ -0,0 +1,1161 @@ +<?php + +/** + * The configuration of SimpleSAMLphp + */ + +$httpUtils = new \SimpleSAML\Utils\HTTP(); + +$config = [ + + /******************************* + | BASIC CONFIGURATION OPTIONS | + *******************************/ + + /* + * Setup the following parameters to match your installation. + * See the user manual for more details. + */ + + /* + * baseurlpath is a *URL path* (not a filesystem path). + * A valid format for 'baseurlpath' is: + * [(http|https)://(hostname|fqdn)[:port]]/[path/to/simplesaml/] + * + * The full url format is useful if your SimpleSAMLphp setup is hosted behind + * a reverse proxy. In that case you can specify the external url here. + * + * Please note that SimpleSAMLphp will then redirect all queries to the + * external url, no matter where you come from (direct access or via the + * reverse proxy). + */ + 'baseurlpath' => 'simplesaml/', + + /* + * The 'application' configuration array groups a set configuration options + * relative to an application protected by SimpleSAMLphp. + */ + //'application' => [ + /* + * The 'baseURL' configuration option allows you to specify a protocol, + * host and optionally a port that serves as the canonical base for all + * your application's URLs. This is useful when the environment + * observed in the server differs from the one observed by end users, + * for example, when using a load balancer to offload TLS. + * + * Note that this configuration option does not allow setting a path as + * part of the URL. If your setup involves URL rewriting or any other + * tricks that would result in SimpleSAMLphp observing a URL for your + * application's scripts different than the canonical one, you will + * need to compute the right URLs yourself and pass them dynamically + * to SimpleSAMLphp's API. + */ + //'baseURL' => 'https://example.com', + //], + + /* + * The following settings are *filesystem paths* which define where + * SimpleSAMLphp can find or write the following things: + * - 'certdir': The base directory for certificate and key material. + * - 'loggingdir': Where to write logs. + * - 'datadir': Storage of general data. + * - 'tempdir': Saving temporary files. SimpleSAMLphp will attempt to create + * this directory if it doesn't exist. + * When specified as a relative path, this is relative to the SimpleSAMLphp + * root directory. + */ + 'certdir' => 'cert/', + 'loggingdir' => 'log/', + 'datadir' => 'data/', + 'tempdir' => '/tmp/simplesaml', + + /* + * Some information about the technical persons running this installation. + * The email address will be used as the recipient address for error reports, and + * also as the technical contact in generated metadata. + */ + 'technicalcontact_name' => 'Administrator', + 'technicalcontact_email' => 'na@example.org', + + /* + * (Optional) The method by which email is delivered. Defaults to mail which utilizes the + * PHP mail() function. + * + * Valid options are: mail, sendmail and smtp. + */ + //'mail.transport.method' => 'smtp', + + /* + * Set the transport options for the transport method specified. The valid settings are relative to the + * selected transport method. + */ + // // smtp mail transport options + // 'mail.transport.options' => [ + // 'host' => 'mail.example.org', // required + // 'port' => 25, // optional + // 'username' => 'user@example.org', // optional: if set, enables smtp authentication + // 'password' => 'password', // optional: if set, enables smtp authentication + // 'security' => 'tls', // optional: defaults to no smtp security + // 'smtpOptions' => [], // optional: passed to stream_context_create when connecting via SMTP + // ], + // // sendmail mail transport options + // 'mail.transport.options' => [ + // 'path' => '/usr/sbin/sendmail' // optional: defaults to php.ini path + // ], + + /* + * The envelope from address for outgoing emails. + * This should be in a domain that has your application's IP addresses in its SPF record + * to prevent it from being rejected by mail filters. + */ + //'sendmail_from' => 'no-reply@example.org', + + /* + * The timezone of the server. This option should be set to the timezone you want + * SimpleSAMLphp to report the time in. The default is to guess the timezone based + * on your system timezone. + * + * See this page for a list of valid timezones: http://php.net/manual/en/timezones.php + */ + 'timezone' => null, + + + + /********************************** + | SECURITY CONFIGURATION OPTIONS | + **********************************/ + + /* + * This is a secret salt used by SimpleSAMLphp when it needs to generate a secure hash + * of a value. It must be changed from its default value to a secret value. The value of + * 'secretsalt' can be any valid string of any length. + * + * A possible way to generate a random salt is by running the following command from a unix shell: + * LC_ALL=C tr -c -d '0123456789abcdefghijklmnopqrstuvwxyz' </dev/urandom | dd bs=32 count=1 2>/dev/null;echo + */ + 'secretsalt' => 'defaultsecretsalt', + + /* + * This password must be kept secret, and modified from the default value 123. + * This password will give access to the installation page of SimpleSAMLphp with + * metadata listing and diagnostics pages. + * You can also put a hash here; run "bin/pwgen.php" to generate one. + */ + 'auth.adminpassword' => '123', + + /* + * Set this option to true if you want to require administrator password to access the metadata. + */ + 'admin.protectmetadata' => false, + + /* + * Set this option to false if you don't want SimpleSAMLphp to check for new stable releases when + * visiting the configuration tab in the web interface. + */ + 'admin.checkforupdates' => true, + + /* + * Array of domains that are allowed when generating links or redirects + * to URLs. SimpleSAMLphp will use this option to determine whether to + * to consider a given URL valid or not, but you should always validate + * URLs obtained from the input on your own (i.e. ReturnTo or RelayState + * parameters obtained from the $_REQUEST array). + * + * SimpleSAMLphp will automatically add your own domain (either by checking + * it dynamically, or by using the domain defined in the 'baseurlpath' + * directive, the latter having precedence) to the list of trusted domains, + * in case this option is NOT set to NULL. In that case, you are explicitly + * telling SimpleSAMLphp to verify URLs. + * + * Set to an empty array to disallow ALL redirects or links pointing to + * an external URL other than your own domain. This is the default behaviour. + * + * Set to NULL to disable checking of URLs. DO NOT DO THIS UNLESS YOU KNOW + * WHAT YOU ARE DOING! + * + * Example: + * 'trusted.url.domains' => ['sp.example.com', 'app.example.com'], + */ + 'trusted.url.domains' => [], + + /* + * Enable regular expression matching of trusted.url.domains. + * + * Set to true to treat the values in trusted.url.domains as regular + * expressions. Set to false to do exact string matching. + * + * If enabled, the start and end delimiters ('^' and '$') will be added to + * all regular expressions in trusted.url.domains. + */ + 'trusted.url.regex' => false, + + /* + * Enable secure POST from HTTPS to HTTP. + * + * If you have some SP's on HTTP and IdP is normally on HTTPS, this option + * enables secure POSTing to HTTP endpoint without warning from browser. + * + * For this to work, module.php/core/postredirect.php must be accessible + * also via HTTP on IdP, e.g. if your IdP is on + * https://idp.example.org/ssp/, then + * http://idp.example.org/ssp/module.php/core/postredirect.php must be accessible. + */ + 'enable.http_post' => false, + + /* + * Set the allowed clock skew between encrypting/decrypting assertions + * + * If you have an server that is constantly out of sync, this option + * allows you to adjust the allowed clock-skew. + * + * Allowed range: 180 - 300 + * Defaults to 180. + */ + 'assertion.allowed_clock_skew' => 180, + + + /************************ + | ERRORS AND DEBUGGING | + ************************/ + + /* + * The 'debug' option allows you to control how SimpleSAMLphp behaves in certain + * situations where further action may be taken + * + * It can be left unset, in which case, debugging is switched off for all actions. + * If set, it MUST be an array containing the actions that you want to enable, or + * alternatively a hashed array where the keys are the actions and their + * corresponding values are booleans enabling or disabling each particular action. + * + * SimpleSAMLphp provides some pre-defined actions, though modules could add new + * actions here. Refer to the documentation of every module to learn if they + * allow you to set any more debugging actions. + * + * The pre-defined actions are: + * + * - 'saml': this action controls the logging of SAML messages exchanged with other + * entities. When enabled ('saml' is present in this option, or set to true), all + * SAML messages will be logged, including plaintext versions of encrypted + * messages. + * + * - 'backtraces': this action controls the logging of error backtraces so you + * can debug any possible errors happening in SimpleSAMLphp. + * + * - 'validatexml': this action allows you to validate SAML documents against all + * the relevant XML schemas. SAML 1.1 messages or SAML metadata parsed with + * the XML to SimpleSAMLphp metadata converter or the metaedit module will + * validate the SAML documents if this option is enabled. + * + * If you want to disable debugging completely, unset this option or set it to an + * empty array. + */ + 'debug' => [ + 'saml' => false, + 'backtraces' => true, + 'validatexml' => false, + ], + + /* + * When 'showerrors' is enabled, all error messages and stack traces will be output + * to the browser. + * + * When 'errorreporting' is enabled, a form will be presented for the user to report + * the error to 'technicalcontact_email'. + */ + 'showerrors' => true, + 'errorreporting' => true, + + /* + * Custom error show function called from SimpleSAML\Error\Error::show. + * See docs/simplesamlphp-errorhandling.txt for function code example. + * + * Example: + * 'errors.show_function' => ['SimpleSAML\Module\example\Error', 'show'], + */ + + + + /************************** + | LOGGING AND STATISTICS | + **************************/ + + /* + * Define the minimum log level to log. Available levels: + * - SimpleSAML\Logger::ERR No statistics, only errors + * - SimpleSAML\Logger::WARNING No statistics, only warnings/errors + * - SimpleSAML\Logger::NOTICE Statistics and errors + * - SimpleSAML\Logger::INFO Verbose logs + * - SimpleSAML\Logger::DEBUG Full debug logs - not recommended for production + * + * Choose logging handler. + * + * Options: [syslog,file,errorlog,stderr] + * + */ + 'logging.level' => SimpleSAML\Logger::NOTICE, + 'logging.handler' => 'syslog', + + /* + * Specify the format of the logs. Its use varies depending on the log handler used (for instance, you cannot + * control here how dates are displayed when using the syslog or errorlog handlers), but in general the options + * are: + * + * - %date{<format>}: the date and time, with its format specified inside the brackets. See the PHP documentation + * of the date() function for more information on the format. If the brackets are omitted, the standard + * format is applied. This can be useful if you just want to control the placement of the date, but don't care + * about the format. + * + * - %process: the name of the SimpleSAMLphp process. Remember you can configure this in the 'logging.processname' + * option below. + * + * - %level: the log level (name or number depending on the handler used). + * + * - %stat: if the log entry is intended for statistical purposes, it will print the string 'STAT ' (bear in mind + * the trailing space). + * + * - %trackid: the track ID, an identifier that allows you to track a single session. + * + * - %srcip: the IP address of the client. If you are behind a proxy, make sure to modify the + * $_SERVER['REMOTE_ADDR'] variable on your code accordingly to the X-Forwarded-For header. + * + * - %msg: the message to be logged. + * + */ + //'logging.format' => '%date{M j H:i:s} %process %level %stat[%trackid] %msg', + + /* + * Choose which facility should be used when logging with syslog. + * + * These can be used for filtering the syslog output from SimpleSAMLphp into its + * own file by configuring the syslog daemon. + * + * See the documentation for openlog (http://php.net/manual/en/function.openlog.php) for available + * facilities. Note that only LOG_USER is valid on windows. + * + * The default is to use LOG_LOCAL5 if available, and fall back to LOG_USER if not. + */ + 'logging.facility' => defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER, + + /* + * The process name that should be used when logging to syslog. + * The value is also written out by the other logging handlers. + */ + 'logging.processname' => 'simplesamlphp', + + /* + * Logging: file - Logfilename in the loggingdir from above. + */ + 'logging.logfile' => 'simplesamlphp.log', + + /* + * This is an array of outputs. Each output has at least a 'class' option, which + * selects the output. + */ + 'statistics.out' => [// Log statistics to the normal log. + /* + [ + 'class' => 'core:Log', + 'level' => 'notice', + ], + */ + // Log statistics to files in a directory. One file per day. + /* + [ + 'class' => 'core:File', + 'directory' => '/var/log/stats', + ], + */ + ], + + + + /*********************** + | PROXY CONFIGURATION | + ***********************/ + + /* + * Proxy to use for retrieving URLs. + * + * Example: + * 'proxy' => 'tcp://proxy.example.com:5100' + */ + 'proxy' => null, + + /* + * Username/password authentication to proxy (Proxy-Authorization: Basic) + * Example: + * 'proxy.auth' = 'myuser:password' + */ + //'proxy.auth' => 'myuser:password', + + + + /************************** + | DATABASE CONFIGURATION | + **************************/ + + /* + * This database configuration is optional. If you are not using + * core functionality or modules that require a database, you can + * skip this configuration. + */ + + /* + * Database connection string. + * Ensure that you have the required PDO database driver installed + * for your connection string. + */ + 'database.dsn' => 'mysql:host=localhost;dbname=saml', + + /* + * SQL database credentials + */ + 'database.username' => 'simplesamlphp', + 'database.password' => 'secret', + 'database.options' => [], + + /* + * (Optional) Table prefix + */ + 'database.prefix' => '', + + /* + * (Optional) Driver options + */ + 'database.driver_options' => [], + + /* + * True or false if you would like a persistent database connection + */ + 'database.persistent' => false, + + /* + * Database secondary configuration is optional as well. If you are only + * running a single database server, leave this blank. If you have + * a primary/secondary configuration, you can define as many secondary servers + * as you want here. Secondaries will be picked at random to be queried from. + * + * Configuration options in the secondary array are exactly the same as the + * options for the primary (shown above) with the exception of the table + * prefix and driver options. + */ + 'database.secondaries' => [ + /* + [ + 'dsn' => 'mysql:host=mysecondary;dbname=saml', + 'username' => 'simplesamlphp', + 'password' => 'secret', + 'persistent' => false, + ], + */ + ], + + + + /************* + | PROTOCOLS | + *************/ + + /* + * Which functionality in SimpleSAMLphp do you want to enable. Normally you would enable only + * one of the functionalities below, but in some cases you could run multiple functionalities. + * In example when you are setting up a federation bridge. + */ + 'enable.saml20-idp' => false, + 'enable.adfs-idp' => false, + + + + /*********** + | MODULES | + ***********/ + + /* + * Configuration for enabling/disabling modules. By default the 'core', 'admin' and 'saml' modules are enabled. + * + * Example: + * + * 'module.enable' => [ + * 'exampleauth' => true, // Setting to TRUE enables. + * 'consent' => false, // Setting to FALSE disables. + * 'core' => null, // Unset or NULL uses default. + * ], + */ + + 'module.enable' => [ + 'exampleauth' => false, + 'core' => true, + 'admin' => true, + 'saml' => true + ], + + + /************************* + | SESSION CONFIGURATION | + *************************/ + + /* + * This value is the duration of the session in seconds. Make sure that the time duration of + * cookies both at the SP and the IdP exceeds this duration. + */ + 'session.duration' => 8 * (60 * 60), // 8 hours. + + /* + * Sets the duration, in seconds, data should be stored in the datastore. As the data store is used for + * login and logout requests, this option will control the maximum time these operations can take. + * The default is 4 hours (4*60*60) seconds, which should be more than enough for these operations. + */ + 'session.datastore.timeout' => (4 * 60 * 60), // 4 hours + + /* + * Sets the duration, in seconds, auth state should be stored. + */ + 'session.state.timeout' => (60 * 60), // 1 hour + + /* + * Option to override the default settings for the session cookie name + */ + 'session.cookie.name' => 'SimpleSAMLSessionID', + + /* + * Expiration time for the session cookie, in seconds. + * + * Defaults to 0, which means that the cookie expires when the browser is closed. + * + * Example: + * 'session.cookie.lifetime' => 30*60, + */ + 'session.cookie.lifetime' => 0, + + /* + * Limit the path of the cookies. + * + * Can be used to limit the path of the cookies to a specific subdirectory. + * + * Example: + * 'session.cookie.path' => '/simplesaml/', + */ + 'session.cookie.path' => '/', + + /* + * Cookie domain. + * + * Can be used to make the session cookie available to several domains. + * + * Example: + * 'session.cookie.domain' => '.example.org', + */ + 'session.cookie.domain' => '', + + /* + * Set the secure flag in the cookie. + * + * Set this to TRUE if the user only accesses your service + * through https. If the user can access the service through + * both http and https, this must be set to FALSE. + */ + 'session.cookie.secure' => true, + + /* + * Set the SameSite attribute in the cookie. + * + * You can set this to the strings 'None', 'Lax', or 'Strict' to support + * the RFC6265bis SameSite cookie attribute. If set to null, no SameSite + * attribute will be sent. + * + * A value of "None" is required to properly support cross-domain POST + * requests which are used by different SAML bindings. Because some older + * browsers do not support this value, the canSetSameSiteNone function + * can be called to only set it for compatible browsers. + * + * You must also set the 'session.cookie.secure' value above to true. + * + * Example: + * 'session.cookie.samesite' => 'None', + */ + 'session.cookie.samesite' => $httpUtils->canSetSameSiteNone() ? 'None' : null, + + /* + * Options to override the default settings for php sessions. + */ + 'session.phpsession.cookiename' => 'SimpleSAML', + 'session.phpsession.savepath' => null, + 'session.phpsession.httponly' => true, + + /* + * Option to override the default settings for the auth token cookie + */ + 'session.authtoken.cookiename' => 'SimpleSAMLAuthToken', + + /* + * Options for remember me feature for IdP sessions. Remember me feature + * has to be also implemented in authentication source used. + * + * Option 'session.cookie.lifetime' should be set to zero (0), i.e. cookie + * expires on browser session if remember me is not checked. + * + * Session duration ('session.duration' option) should be set according to + * 'session.rememberme.lifetime' option. + * + * It's advised to use remember me feature with session checking function + * defined with 'session.check_function' option. + */ + 'session.rememberme.enable' => false, + 'session.rememberme.checked' => false, + 'session.rememberme.lifetime' => (14 * 86400), + + /* + * Custom function for session checking called on session init and loading. + * See docs/simplesamlphp-advancedfeatures.txt for function code example. + * + * Example: + * 'session.check_function' => ['\SimpleSAML\Module\example\Util', 'checkSession'], + */ + + + + /************************** + | MEMCACHE CONFIGURATION | + **************************/ + + /* + * Configuration for the 'memcache' session store. This allows you to store + * multiple redundant copies of sessions on different memcache servers. + * + * 'memcache_store.servers' is an array of server groups. Every data + * item will be mirrored in every server group. + * + * Each server group is an array of servers. The data items will be + * load-balanced between all servers in each server group. + * + * Each server is an array of parameters for the server. The following + * options are available: + * - 'hostname': This is the hostname or ip address where the + * memcache server runs. This is the only required option. + * - 'port': This is the port number of the memcache server. If this + * option isn't set, then we will use the 'memcache.default_port' + * ini setting. This is 11211 by default. + * + * When using the "memcache" extension, the following options are also + * supported: + * - 'weight': This sets the weight of this server in this server + * group. http://php.net/manual/en/function.Memcache-addServer.php + * contains more information about the weight option. + * - 'timeout': The timeout for this server. By default, the timeout + * is 3 seconds. + * + * Example of redundant configuration with load balancing: + * This configuration makes it possible to lose both servers in the + * a-group or both servers in the b-group without losing any sessions. + * Note that sessions will be lost if one server is lost from both the + * a-group and the b-group. + * + * 'memcache_store.servers' => [ + * [ + * ['hostname' => 'mc_a1'], + * ['hostname' => 'mc_a2'], + * ], + * [ + * ['hostname' => 'mc_b1'], + * ['hostname' => 'mc_b2'], + * ], + * ], + * + * Example of simple configuration with only one memcache server, + * running on the same computer as the web server: + * Note that all sessions will be lost if the memcache server crashes. + * + * 'memcache_store.servers' => [ + * [ + * ['hostname' => 'localhost'], + * ], + * ], + * + * Additionally, when using the "memcached" extension, unique keys must + * be provided for each group of servers if persistent connections are + * desired. Each server group can also have an "options" indexed array + * with the options desired for the given group: + * + * 'memcache_store.servers' => [ + * 'memcache_group_1' => [ + * 'options' => [ + * \Memcached::OPT_BINARY_PROTOCOL => true, + * \Memcached::OPT_NO_BLOCK => true, + * \Memcached::OPT_TCP_NODELAY => true, + * \Memcached::OPT_LIBKETAMA_COMPATIBLE => true, + * ], + * ['hostname' => '127.0.0.1', 'port' => 11211], + * ['hostname' => '127.0.0.2', 'port' => 11211], + * ], + * + * 'memcache_group_2' => [ + * 'options' => [ + * \Memcached::OPT_BINARY_PROTOCOL => true, + * \Memcached::OPT_NO_BLOCK => true, + * \Memcached::OPT_TCP_NODELAY => true, + * \Memcached::OPT_LIBKETAMA_COMPATIBLE => true, + * ], + * ['hostname' => '127.0.0.3', 'port' => 11211], + * ['hostname' => '127.0.0.4', 'port' => 11211], + * ], + * ], + * + */ + 'memcache_store.servers' => [ + [ + ['hostname' => 'localhost'], + ], + ], + + /* + * This value allows you to set a prefix for memcache-keys. The default + * for this value is 'simpleSAMLphp', which is fine in most cases. + * + * When running multiple instances of SSP on the same host, and more + * than one instance is using memcache, you probably want to assign + * a unique value per instance to this setting to avoid data collision. + */ + 'memcache_store.prefix' => '', + + /* + * This value is the duration data should be stored in memcache. Data + * will be dropped from the memcache servers when this time expires. + * The time will be reset every time the data is written to the + * memcache servers. + * + * This value should always be larger than the 'session.duration' + * option. Not doing this may result in the session being deleted from + * the memcache servers while it is still in use. + * + * Set this value to 0 if you don't want data to expire. + * + * Note: The oldest data will always be deleted if the memcache server + * runs out of storage space. + */ + 'memcache_store.expires' => 36 * (60 * 60), // 36 hours. + + + + /************************************* + | LANGUAGE AND INTERNATIONALIZATION | + *************************************/ + + /* + * Languages available, RTL languages, and what language is the default. + */ + 'language.available' => [ + 'en', 'no', 'nn', 'se', 'da', 'de', 'sv', 'fi', 'es', 'ca', 'fr', 'it', 'nl', 'lb', + 'cs', 'sl', 'lt', 'hr', 'hu', 'pl', 'pt', 'pt-br', 'tr', 'ja', 'zh', 'zh-tw', 'ru', + 'et', 'he', 'id', 'sr', 'lv', 'ro', 'eu', 'el', 'af', 'zu', 'xh', 'st', + ], + 'language.rtl' => ['ar', 'dv', 'fa', 'ur', 'he'], + 'language.default' => 'en', + + /* + * Options to override the default settings for the language parameter + */ + 'language.parameter.name' => 'language', + 'language.parameter.setcookie' => true, + + /* + * Options to override the default settings for the language cookie + */ + 'language.cookie.name' => 'language', + 'language.cookie.domain' => '', + 'language.cookie.path' => '/', + 'language.cookie.secure' => true, + 'language.cookie.httponly' => false, + 'language.cookie.lifetime' => (60 * 60 * 24 * 900), + 'language.cookie.samesite' => $httpUtils->canSetSameSiteNone() ? 'None' : null, + + /** + * Custom getLanguage function called from SimpleSAML\Locale\Language::getLanguage(). + * Function should return language code of one of the available languages or NULL. + * See SimpleSAML\Locale\Language::getLanguage() source code for more info. + * + * This option can be used to implement a custom function for determining + * the default language for the user. + * + * Example: + * 'language.get_language_function' => ['\SimpleSAML\Module\example\Template', 'getLanguage'], + */ + + /************** + | APPEARANCE | + **************/ + + /* + * Which theme directory should be used? + */ + 'theme.use' => 'default', + + /* + * Set this option to the text you would like to appear at the header of each page. Set to false if you don't want + * any text to appear in the header. + */ + //'theme.header' => 'SimpleSAMLphp' + + /** + * A template controller, if any. + * + * Used to intercept certain parts of the template handling, while keeping away unwanted/unexpected hooks. Set + * the 'theme.controller' configuration option to a class that implements the + * \SimpleSAML\XHTML\TemplateControllerInterface interface to use it. + */ + //'theme.controller' => '', + + /* + * Templating options + * + * By default, twig templates are not cached. To turn on template caching: + * Set 'template.cache' to an absolute path pointing to a directory that + * SimpleSAMLphp has read and write permissions to. + */ + //'template.cache' => '', + + /* + * Set the 'template.auto_reload' to true if you would like SimpleSAMLphp to + * recompile the templates (when using the template cache) if the templates + * change. If you don't want to check the source templates for every request, + * set it to false. + */ + 'template.auto_reload' => false, + + /* + * Set this option to true to indicate that your installation of SimpleSAMLphp + * is running in a production environment. This will affect the way resources + * are used, offering an optimized version when running in production, and an + * easy-to-debug one when not. Set it to false when you are testing or + * developing the software, in which case a banner will be displayed to remind + * users that they're dealing with a non-production instance. + * + * Defaults to true. + */ + 'production' => true, + + /* + * SimpleSAMLphp modules can host static resources which are served through PHP. + * The serving of the resources can be configured through these settings. + */ + 'assets' => [ + /* + * These settings adjust the caching headers that are sent + * when serving static resources. + */ + 'caching' => [ + /* + * Amount of seconds before the resource should be fetched again + */ + 'max_age' => 86400, + /* + * Calculate a checksum of every file and send it to the browser + * This allows the browser to avoid downloading assets again in situations + * where the Last-Modified header cannot be trusted, + * for example in cluster setups + * + * Defaults false + */ + 'etag' => false, + ], + ], + + /** + * Set to a full URL if you want to redirect users that land on SimpleSAMLphp's + * front page to somewhere more useful. If left unset, a basic welcome message + * is shown. + */ + //'frontpage.redirect' => 'https://example.com/', + + /********************* + | DISCOVERY SERVICE | + *********************/ + + /* + * Whether the discovery service should allow the user to save his choice of IdP. + */ + 'idpdisco.enableremember' => true, + 'idpdisco.rememberchecked' => true, + + /* + * The disco service only accepts entities it knows. + */ + 'idpdisco.validate' => true, + + 'idpdisco.extDiscoveryStorage' => null, + + /* + * IdP Discovery service look configuration. + * Wether to display a list of idp or to display a dropdown box. For many IdP' a dropdown box + * gives the best use experience. + * + * When using dropdown box a cookie is used to highlight the previously chosen IdP in the dropdown. + * This makes it easier for the user to choose the IdP + * + * Options: [links,dropdown] + */ + 'idpdisco.layout' => 'dropdown', + + + + /************************************* + | AUTHENTICATION PROCESSING FILTERS | + *************************************/ + + /* + * Tracker processing filters that will be executed for all IdPs + */ + 'authproc.idp' => [ + /* Enable the authproc filter below to add URN prefixes to all attributes + 10 => array[ + 'class' => 'core:AttributeMap', 'addurnprefix' + ], + */ + /* Enable the authproc filter below to automatically generated eduPersonTargetedID. + 20 => 'core:TargetedID', + */ + + // Adopts language from attribute to use in UI + 30 => 'core:LanguageAdaptor', + + 45 => [ + 'class' => 'core:StatisticsWithAttribute', + 'attributename' => 'realm', + 'type' => 'saml20-idp-SSO', + ], + + /* When called without parameters, it will fallback to filter attributes 'the old way' + * by checking the 'attributes' parameter in metadata on IdP hosted and SP remote. + */ + 50 => 'core:AttributeLimit', + + /* + * Search attribute "distinguishedName" for pattern and replaces if found + */ + /* + 60 => [ + 'class' => 'core:AttributeAlter', + 'pattern' => '/OU=studerende/', + 'replacement' => 'Student', + 'subject' => 'distinguishedName', + '%replace', + ], + */ + + /* + * Consent module is enabled (with no permanent storage, using cookies). + */ + /* + 90 => [ + 'class' => 'consent:Consent', + 'store' => 'consent:Cookie', + 'focus' => 'yes', + 'checked' => true + ], + */ + // If language is set in Consent module it will be added as an attribute. + 99 => 'core:LanguageAdaptor', + ], + + /* + * Tracker processing filters that will be executed for all SPs + */ + 'authproc.sp' => [ + /* + 10 => [ + 'class' => 'core:AttributeMap', 'removeurnprefix' + ], + */ + + /* + * Generate the 'group' attribute populated from other variables, including eduPersonAffiliation. + 60 => [ + 'class' => 'core:GenerateGroups', 'eduPersonAffiliation' + ], + */ + /* + * All users will be members of 'users' and 'members' + */ + /* + 61 => [ + 'class' => 'core:AttributeAdd', 'groups' => ['users', 'members'] + ], + */ + + // Adopts language from attribute to use in UI + 90 => 'core:LanguageAdaptor', + ], + + + + /************************** + | METADATA CONFIGURATION | + **************************/ + + /* + * This option allows you to specify a directory for your metadata outside of the standard metadata directory + * included in the standard distribution of the software. + */ + 'metadatadir' => 'metadata', + + /* + * This option configures the metadata sources. The metadata sources is given as an array with + * different metadata sources. When searching for metadata, SimpleSAMLphp will search through + * the array from start to end. + * + * Each element in the array is an associative array which configures the metadata source. + * The type of the metadata source is given by the 'type' element. For each type we have + * different configuration options. + * + * Flat file metadata handler: + * - 'type': This is always 'flatfile'. + * - 'directory': The directory we will load the metadata files from. The default value for + * this option is the value of the 'metadatadir' configuration option, or + * 'metadata/' if that option is unset. + * + * XML metadata handler: + * This metadata handler parses an XML file with either an EntityDescriptor element or an + * EntitiesDescriptor element. The XML file may be stored locally, or (for debugging) on a remote + * web server. + * The XML metadata handler defines the following options: + * - 'type': This is always 'xml'. + * - 'file': Path to the XML file with the metadata. + * - 'url': The URL to fetch metadata from. THIS IS ONLY FOR DEBUGGING - THERE IS NO CACHING OF THE RESPONSE. + * + * MDQ metadata handler: + * This metadata handler looks up for the metadata of an entity at the given MDQ server. + * The MDQ metadata handler defines the following options: + * - 'type': This is always 'mdq'. + * - 'server': Base URL of the MDQ server. Mandatory. + * - 'validateCertificate': The certificates file that may be used to sign the metadata. You don't need this + * option if you don't want to validate the signature on the metadata. Optional. + * - 'cachedir': Directory where metadata can be cached. Optional. + * - 'cachelength': Maximum time metadata can be cached, in seconds. Defaults to 24 + * hours (86400 seconds). Optional. + * + * PDO metadata handler: + * This metadata handler looks up metadata of an entity stored in a database. + * + * Note: If you are using the PDO metadata handler, you must configure the database + * options in this configuration file. + * + * The PDO metadata handler defines the following options: + * - 'type': This is always 'pdo'. + * + * Examples: + * + * This example defines two flatfile sources. One is the default metadata directory, the other + * is a metadata directory with auto-generated metadata files. + * + * 'metadata.sources' => [ + * ['type' => 'flatfile'], + * ['type' => 'flatfile', 'directory' => 'metadata-generated'], + * ], + * + * This example defines a flatfile source and an XML source. + * 'metadata.sources' => [ + * ['type' => 'flatfile'], + * ['type' => 'xml', 'file' => 'idp.example.org-idpMeta.xml'], + * ], + * + * This example defines an mdq source. + * 'metadata.sources' => [ + * [ + * 'type' => 'mdq', + * 'server' => 'http://mdq.server.com:8080', + * 'validateCertificate' => [ + * '/var/simplesamlphp/cert/metadata-key.new.crt', + * '/var/simplesamlphp/cert/metadata-key.old.crt' + * ], + * 'cachedir' => '/var/simplesamlphp/mdq-cache', + * 'cachelength' => 86400 + * ] + * ], + * + * This example defines an pdo source. + * 'metadata.sources' => [ + * ['type' => 'pdo'] + * ], + * + * Default: + * 'metadata.sources' => [ + * ['type' => 'flatfile'] + * ], + */ + 'metadata.sources' => [ + ['type' => 'flatfile'], + ], + + /* + * Should signing of generated metadata be enabled by default. + * + * Metadata signing can also be enabled for a individual SP or IdP by setting the + * same option in the metadata for the SP or IdP. + */ + 'metadata.sign.enable' => false, + + /* + * The default key & certificate which should be used to sign generated metadata. These + * are files stored in the cert dir. + * These values can be overridden by the options with the same names in the SP or + * IdP metadata. + * + * If these aren't specified here or in the metadata for the SP or IdP, then + * the 'certificate' and 'privatekey' option in the metadata will be used. + * if those aren't set, signing of metadata will fail. + */ + 'metadata.sign.privatekey' => null, + 'metadata.sign.privatekey_pass' => null, + 'metadata.sign.certificate' => null, + + + /**************************** + | DATA STORE CONFIGURATION | + ****************************/ + + /* + * Configure the data store for SimpleSAMLphp. + * + * - 'phpsession': Limited datastore, which uses the PHP session. + * - 'memcache': Key-value datastore, based on memcache. + * - 'sql': SQL datastore, using PDO. + * - 'redis': Key-value datastore, based on redis. + * + * The default datastore is 'phpsession'. + */ + 'store.type' => 'phpsession', + + /* + * The DSN the sql datastore should connect to. + * + * See http://www.php.net/manual/en/pdo.drivers.php for the various + * syntaxes. + */ + 'store.sql.dsn' => 'sqlite:/path/to/sqlitedatabase.sq3', + + /* + * The username and password to use when connecting to the database. + */ + 'store.sql.username' => null, + 'store.sql.password' => null, + + /* + * The prefix we should use on our tables. + */ + 'store.sql.prefix' => 'SimpleSAMLphp', + + /* + * The driver-options we should pass to the PDO-constructor. + */ + 'store.sql.options' => [], + + /* + * The hostname and port of the Redis datastore instance. + */ + 'store.redis.host' => 'localhost', + 'store.redis.port' => 6379, + + /* + * The prefix we should use on our Redis datastore. + */ + 'store.redis.prefix' => 'SimpleSAMLphp', +]; diff --git a/tests/config-templates/module_accounting.php b/tests/config-templates/module_accounting.php new file mode 100644 index 0000000000000000000000000000000000000000..882170befdeb1d19e4908df67adb8402c21eee02 --- /dev/null +++ b/tests/config-templates/module_accounting.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Providers; +use SimpleSAML\Module\accounting\Stores; +use SimpleSAML\Module\accounting\Trackers; + +$config = [ + + ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME => 'urn:oasis:names:tc:SAML:attribute:subject-id', + + ModuleConfiguration::OPTION_DEFAULT_AUTHENTICATION_SOURCE => 'default-sp', + + ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE => + ModuleConfiguration\AccountingProcessingType::VALUE_SYNCHRONOUS, + + ModuleConfiguration::OPTION_JOBS_STORE => Stores\Jobs\DoctrineDbal\Store::class, + + ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER => + Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class, + + ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [ + // + ], + + ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [ + Stores\Jobs\DoctrineDbal\Store::class => 'doctrine_dbal_pdo_sqlite', + Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class => [ + ModuleConfiguration\ConnectionType::MASTER => 'doctrine_dbal_pdo_sqlite', + ModuleConfiguration\ConnectionType::SLAVE => [ + 'doctrine_dbal_pdo_sqlite_slave', + ], + ], + ], + + ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [ + 'doctrine_dbal_pdo_sqlite' => [ + 'driver' => 'pdo_sqlite', + 'memory' => true, + 'table_prefix' => '', + ], + 'doctrine_dbal_pdo_sqlite_slave' => [ + 'driver' => 'pdo_sqlite', + 'memory' => true, + 'table_prefix' => '', + ], + ], + + ModuleConfiguration::OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME => null, + + ModuleConfiguration::OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED => 10, + + ModuleConfiguration::OPTION_TRACKER_DATA_RETENTION_POLICY => null, + + ModuleConfiguration::OPTION_CRON_TAG_FOR_TRACKER_DATA_RETENTION_POLICY => + 'accounting_tracker_data_retention_policy', + + ModuleConfiguration::OPTION_CRON_TAG_FOR_JOB_RUNNER => 'accounting_job_runner', +]; diff --git a/tests/src/Auth/Process/AccountingTest.php b/tests/src/Auth/Process/AccountingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7e932e0856e83dfc5c9c624dbbda1d90df3ebb61 --- /dev/null +++ b/tests/src/Auth/Process/AccountingTest.php @@ -0,0 +1,182 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Auth\Process; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Auth\Process\Accounting; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker; +use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder; +use SimpleSAML\Test\Module\accounting\Constants\StateArrays; + +/** + * @covers \SimpleSAML\Module\accounting\Auth\Process\Accounting + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder + * @uses \SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job + * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob + * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class AccountingTest extends TestCase +{ + protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub; + protected \PHPUnit\Framework\MockObject\MockObject $loggerMock; + protected array $filterConfig; + protected \PHPUnit\Framework\MockObject\MockObject $jobsStoreBuilderMock; + protected \PHPUnit\Framework\MockObject\MockObject $authenticationDataTrackerBuilderMock; + protected \PHPUnit\Framework\MockObject\MockObject $jobsStoreMock; + protected \PHPUnit\Framework\MockObject\MockObject $trackerMock; + protected array $sampleState; + protected HelpersManager $helpersManager; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + + $this->loggerMock = $this->createMock(LoggerInterface::class); + + $this->jobsStoreBuilderMock = $this->createMock(JobsStoreBuilder::class); + $this->authenticationDataTrackerBuilderMock = + $this->createMock(AuthenticationDataTrackerBuilder::class); + + $this->jobsStoreMock = $this->createMock(Store::class); + $this->trackerMock = $this->createMock(Tracker::class); + + $this->sampleState = StateArrays::FULL; + + $this->filterConfig = []; + + $this->helpersManager = new HelpersManager(); + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress InvalidArgument */ + $this->assertInstanceOf( + Accounting::class, + new Accounting( + $this->filterConfig, + null, + $this->moduleConfigurationStub, + $this->loggerMock, + ) + ); + + /** @psalm-suppress InvalidArgument */ + $this->assertInstanceOf( + Accounting::class, + new Accounting( + $this->filterConfig, + null, + $this->moduleConfigurationStub, + $this->loggerMock, + $this->helpersManager, + $this->jobsStoreBuilderMock, + $this->authenticationDataTrackerBuilderMock + ) + ); + } + + public function testCreatesJobOnAsynchronousAccountingType(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + $this->moduleConfigurationStub->method('getJobsStoreClass') + ->willReturn(Store::class); + + $this->jobsStoreMock->expects($this->once()) + ->method('enqueue') + ->with($this->isInstanceOf(Event\Job::class)); + + $this->jobsStoreBuilderMock->expects($this->once()) + ->method('build') + ->with($this->equalTo(Store::class)) + ->willReturn($this->jobsStoreMock); + + $this->authenticationDataTrackerBuilderMock + ->expects($this->never()) + ->method('build'); + + /** @psalm-suppress InvalidArgument */ + (new Accounting( + $this->filterConfig, + null, + $this->moduleConfigurationStub, + $this->loggerMock, + $this->helpersManager, + $this->jobsStoreBuilderMock, + $this->authenticationDataTrackerBuilderMock + ))->process($this->sampleState); + } + + public function testAccountingRunsOnSynchronousType(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_SYNCHRONOUS); + + $this->moduleConfigurationStub->method('getDefaultDataTrackerAndProviderClass') + ->willReturn(Tracker::class); + $this->moduleConfigurationStub->method('getAdditionalTrackers')->willReturn([]); + + $this->jobsStoreBuilderMock->expects($this->never()) + ->method('build'); + + $this->trackerMock + ->expects($this->once()) + ->method('process') + ->with($this->isInstanceOf(Event::class)); + + $this->authenticationDataTrackerBuilderMock + ->expects($this->once()) + ->method('build') + ->with($this->equalTo(Tracker::class)) + ->willReturn($this->trackerMock); + + /** @psalm-suppress InvalidArgument */ + (new Accounting( + $this->filterConfig, + null, + $this->moduleConfigurationStub, + $this->loggerMock, + $this->helpersManager, + $this->jobsStoreBuilderMock, + $this->authenticationDataTrackerBuilderMock + ))->process($this->sampleState); + } + + public function testLogsErrorOnException(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willThrowException(new InvalidConfigurationException('test')); + + $this->loggerMock->expects($this->once())->method('error'); + + /** @psalm-suppress InvalidArgument */ + (new Accounting( + $this->filterConfig, + null, + $this->moduleConfigurationStub, + $this->loggerMock, + $this->helpersManager, + $this->jobsStoreBuilderMock, + $this->authenticationDataTrackerBuilderMock + ))->process($this->sampleState); + } +} diff --git a/tests/src/Constants/ConnectionParameters.php b/tests/src/Constants/ConnectionParameters.php new file mode 100644 index 0000000000000000000000000000000000000000..9aad77aa5077731e68360b81c282e602c002dca8 --- /dev/null +++ b/tests/src/Constants/ConnectionParameters.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Constants; + +class ConnectionParameters +{ + public const DBAL_SQLITE_MEMORY = ['driver' => 'pdo_sqlite', 'memory' => true,]; +} diff --git a/tests/src/Constants/DateTime.php b/tests/src/Constants/DateTime.php new file mode 100644 index 0000000000000000000000000000000000000000..df07b1951637fed22e89289a63a664846db6a112 --- /dev/null +++ b/tests/src/Constants/DateTime.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Constants; + +final class DateTime +{ + public const DEFAULT_FORMAT = 'Y-m-d H:i:s'; +} diff --git a/tests/src/Constants/RawRowResult.php b/tests/src/Constants/RawRowResult.php new file mode 100644 index 0000000000000000000000000000000000000000..e4224dfa1814ffbab19be377f82b74f1831d4abc --- /dev/null +++ b/tests/src/Constants/RawRowResult.php @@ -0,0 +1,27 @@ +<?php +// phpcs:ignoreFile + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Constants; + +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; + +class RawRowResult +{ + public const CONNECTED_ORGANIZATION = [ + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS => 1, + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT => '2022-02-22 22:22:22', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT => '2022-02-02 22:22:22', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA => 'a:9:{s:19:"SingleLogoutService";a:1:{i:0;a:2:{s:7:"Binding";s:50:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";s:8:"Location";s:121:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp";}}s:24:"AssertionConsumerService";a:2:{i:0;a:3:{s:7:"Binding";s:46:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";s:8:"Location";s:126:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp";s:5:"index";i:0;}i:1;a:3:{s:7:"Binding";s:50:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact";s:8:"Location";s:126:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp";s:5:"index";i:1;}}s:8:"contacts";a:1:{i:0;a:3:{s:12:"emailAddress";s:15:"mivanci@srce.hr";s:9:"givenName";s:15:"Marko Ivančić";s:11:"contactType";s:9:"technical";}}s:4:"name";a:1:{s:2:"en";s:12:"Test service";}s:11:"description";a:1:{s:2:"en";s:27:"Description of test service";}s:16:"OrganizationName";a:1:{s:2:"en";s:17:"Test organization";}s:8:"entityid";s:114:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp";s:14:"metadata-index";s:114:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp";s:12:"metadata-set";s:16:"saml20-sp-remote";}', + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES => 'a:5:{s:33:"urn:oid:0.9.2342.19200300.100.1.1";a:1:{i:0;s:11:"student-uid";}s:32:"urn:oid:1.3.6.1.4.1.5923.1.1.1.1";a:2:{i:0;s:6:"member";i:1;s:7:"student";}s:15:"urn:oid:2.5.4.4";a:1:{i:0;s:10:"student-sn";}s:19:"hrEduPersonUniqueID";a:1:{i:0;s:19:"student@example.org";}s:23:"hrEduPersonPersistentID";a:1:{i:0;s:13:"student123abc";}}', + ]; + + + public const ACTIVITY = [ + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT => '2022-02-22 22:22:22', + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA => 'a:9:{s:19:"SingleLogoutService";a:1:{i:0;a:2:{s:7:"Binding";s:50:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";s:8:"Location";s:121:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp";}}s:24:"AssertionConsumerService";a:2:{i:0;a:3:{s:7:"Binding";s:46:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";s:8:"Location";s:126:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp";s:5:"index";i:0;}i:1;a:3:{s:7:"Binding";s:50:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact";s:8:"Location";s:126:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp";s:5:"index";i:1;}}s:8:"contacts";a:1:{i:0;a:3:{s:12:"emailAddress";s:15:"mivanci@srce.hr";s:9:"givenName";s:15:"Marko Ivančić";s:11:"contactType";s:9:"technical";}}s:4:"name";a:1:{s:2:"en";s:12:"Test service";}s:11:"description";a:1:{s:2:"en";s:27:"Description of test service";}s:16:"OrganizationName";a:1:{s:2:"en";s:17:"Test organization";}s:8:"entityid";s:114:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp";s:14:"metadata-index";s:114:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp";s:12:"metadata-set";s:16:"saml20-sp-remote";}', + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES => 'a:5:{s:33:"urn:oid:0.9.2342.19200300.100.1.1";a:1:{i:0;s:11:"student-uid";}s:32:"urn:oid:1.3.6.1.4.1.5923.1.1.1.1";a:2:{i:0;s:6:"member";i:1;s:7:"student";}s:15:"urn:oid:2.5.4.4";a:1:{i:0;s:10:"student-sn";}s:19:"hrEduPersonUniqueID";a:1:{i:0;s:19:"student@example.org";}s:23:"hrEduPersonPersistentID";a:1:{i:0;s:13:"student123abc";}}', + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_CLIENT_IP_ADDRESS => '172.21.0.1', + ]; +} diff --git a/tests/src/Constants/SerializedJob.php b/tests/src/Constants/SerializedJob.php new file mode 100644 index 0000000000000000000000000000000000000000..703c055d01c3264f3659957d3a30f23a0b544bef --- /dev/null +++ b/tests/src/Constants/SerializedJob.php @@ -0,0 +1,12 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Constants; + +use SimpleSAML\Module\accounting\Entities\Authentication\Event\Job; + +class SerializedJob +{ + public const EVENT = [ + Job::class => 'TODO' + ]; +} diff --git a/tests/src/Constants/StateArrays.php b/tests/src/Constants/StateArrays.php new file mode 100644 index 0000000000000000000000000000000000000000..e18b08236b1be0736aee47816083d497c3d0b23a --- /dev/null +++ b/tests/src/Constants/StateArrays.php @@ -0,0 +1,134 @@ +<?php +// phpcs:ignoreFile + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Constants; + +final class StateArrays +{ + public const FULL = [ + 'Responder' => [0 => '\\SimpleSAML\\Module\\saml\\IdP\\SAML2', 1 => 'sendResponse',], + '\\SimpleSAML\\Auth\\State.exceptionFunc' => [ + 0 => '\\SimpleSAML\\Module\\saml\\IdP\\SAML2', + 1 => 'handleAuthError', + ], + '\\SimpleSAML\\Auth\\State.restartURL' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/SSOService.php?spentityid=https%3A%2F%2Fpc-example.org.hr%3A9074%2Fsimplesamlphp%2Fsimplesamlphp-2-beta-git%2Fmodule.php%2Fsaml%2Fsp%2Fmetadata.php%2Fdefault-sp&RelayState=https%3A%2F%2Flocalhost.someone.from.hr%3A9074%2Fsimplesamlphp%2Fsimplesamlphp-2-beta-git%2Fmodule.php%2Fadmin%2Ftest%2Fdefault-sp&cookieTime=1660912195', + 'SPMetadata' => [ + 'SingleLogoutService' => [ + 0 => [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp', + ], + ], + 'AssertionConsumerService' => [ + 0 => [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp', + 'index' => 0, + ], + 1 => [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact', + 'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp', + 'index' => 1, + ], + ], + 'contacts' => [ + 0 => [ + 'emailAddress' => 'example@org.hr', + 'givenName' => 'Marko Ivančić', + 'contactType' => 'technical', + ], + ], + 'entityid' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp', + 'metadata-index' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp', + 'metadata-set' => 'saml20-sp-remote', + 'name' => 'Test service', + 'description' => 'Test service description' + ], + 'saml:RelayState' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/admin/test/default-sp', + 'saml:RequestId' => null, + 'saml:IDPList' => [], + 'saml:ProxyCount' => null, + 'saml:RequesterID' => null, + 'ForceAuthn' => false, + 'isPassive' => false, + 'saml:ConsumerURL' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp', + 'saml:Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'saml:NameIDFormat' => null, + 'saml:AllowCreate' => true, + 'saml:Extensions' => null, + 'saml:AuthnRequestReceivedAt' => 1660912195.505402, + 'saml:RequestedAuthnContext' => null, + 'core:IdP' => 'saml2:https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php', + 'core:SP' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp', + 'IdPMetadata' => [ + 'host' => 'localhost.someone.from.hr', + 'privatekey' => 'key.pem', + 'certificate' => 'cert.pem', + 'auth' => 'example-userpass', + 'attributes.NameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', + 'authproc' => [100 => ['class' => 'core:AttributeMap', 0 => 'name2oid',],], + 'entityid' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php', + 'metadata-index' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php', + 'metadata-set' => 'saml20-idp-hosted', + ], + 'ReturnCallback' => [0 => '\\SimpleSAML\\IdP', 1 => 'postAuth',], + 'Attributes' => [ + 'hrEduPersonUniqueID' => [0 => 'testuser@primjer.hr',], + 'urn:oid:0.9.2342.19200300.100.1.1' => [0 => 'testuser',], + 'urn:oid:2.5.4.4' => [0 => 'TestSurname', 1 => 'TestSurname2',], + 'urn:oid:2.5.4.42' => [0 => 'TestName',], + 'urn:oid:2.5.4.10' => [0 => 'Testna ustanova',], + 'urn:oid:2.5.4.11' => [0 => 'Testna org jedinica',], + 'hrEduPersonPersistentID' => [0 => 'da4294fb4e5746d57ab6ad88d2daf275',], + 'updatedAt' => [0 => '123456789',], + ], + 'Authority' => 'example-userpass', + 'AuthnInstant' => 1660911943, + 'Expire' => 1660940743, + 'ReturnCall' => [0 => '\\SimpleSAML\\IdP', 1 => 'postAuthProc',], + 'Destination' => [ + 'SingleLogoutService' => [ + 0 => [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp', + ], + ], + 'AssertionConsumerService' => [ + 0 => [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp', + 'index' => 0, + ], + 1 => [ + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact', + 'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp', + 'index' => 1, + ], + ], + 'contacts' => [ + 0 => [ + 'emailAddress' => 'example@org.hr', + 'givenName' => 'Marko Ivančić', + 'contactType' => 'technical', + ], + ], + 'entityid' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp', + 'metadata-index' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp', + 'metadata-set' => 'saml20-sp-remote', + ], + 'Source' => [ + 'host' => 'localhost.someone.from.hr', + 'privatekey' => 'key.pem', + 'certificate' => 'cert.pem', + 'auth' => 'example-userpass', + 'attributes.NameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', + 'authproc' => [100 => ['class' => 'core:AttributeMap', 0 => 'name2oid',],], + 'entityid' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php', + 'metadata-index' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php', + 'metadata-set' => 'saml20-idp-hosted', + ], + '\\SimpleSAML\\Auth\\ProcessingChain.filters' => [], + ]; +} diff --git a/tests/src/Entities/Activity/BagTest.php b/tests/src/Entities/Activity/BagTest.php new file mode 100644 index 0000000000000000000000000000000000000000..023dcf5dcea25d8712e996feb9fe69828e5ce77f --- /dev/null +++ b/tests/src/Entities/Activity/BagTest.php @@ -0,0 +1,26 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Entities\Activity; + +use SimpleSAML\Module\accounting\Entities\Activity; +use SimpleSAML\Module\accounting\Entities\Activity\Bag; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\Activity\Bag + */ +class BagTest extends TestCase +{ + public function testCanAddActivity(): void + { + $activityStub = $this->createStub(Activity::class); + $bag = new Bag(); + + $this->assertEmpty($bag->getAll()); + + /** @psalm-suppress InvalidArgument */ + $bag->add($activityStub); + + $this->assertNotEmpty($bag->getAll()); + } +} diff --git a/tests/src/Entities/ActivityTest.php b/tests/src/Entities/ActivityTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2a35bd27f87167858eb63debcb1409ef8db4a3e6 --- /dev/null +++ b/tests/src/Entities/ActivityTest.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Entities; + +use SimpleSAML\Module\accounting\Entities\Activity; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\ServiceProvider; +use SimpleSAML\Module\accounting\Entities\User; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\Activity + */ +class ActivityTest extends TestCase +{ + /** + * @var \PHPUnit\Framework\MockObject\Stub|ServiceProvider|ServiceProvider&\PHPUnit\Framework\MockObject\Stub + */ + protected $serviceProviderStub; + /** + * @var \PHPUnit\Framework\MockObject\Stub|User|User&\PHPUnit\Framework\MockObject\Stub + */ + protected $userStub; + protected \DateTimeImmutable $happenedAt; + protected string $clientIpAddress; + + public function setUp(): void + { + $this->serviceProviderStub = $this->createStub(ServiceProvider::class); + $this->userStub = $this->createStub(User::class); + $this->happenedAt = new \DateTimeImmutable(); + $this->clientIpAddress = '123.123.123.123'; + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress InvalidArgument */ + $activity = new Activity( + $this->serviceProviderStub, + $this->userStub, + $this->happenedAt, + $this->clientIpAddress + ); + + $this->assertSame($this->serviceProviderStub, $activity->getServiceProvider()); + $this->assertSame($this->userStub, $activity->getUser()); + $this->assertSame($this->happenedAt, $activity->getHappenedAt()); + $this->assertSame($this->clientIpAddress, $activity->getClientIpAddress()); + } +} diff --git a/tests/src/Entities/Authentication/Event/JobTest.php b/tests/src/Entities/Authentication/Event/JobTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a43e3bf892e0dfa1a2c7baae9df31c7c1d8506aa --- /dev/null +++ b/tests/src/Entities/Authentication/Event/JobTest.php @@ -0,0 +1,46 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Entities\Authentication\Event; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\Event\Job; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Test\Module\accounting\Constants\StateArrays; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job + * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State + * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class JobTest extends TestCase +{ + public function testCanCreateInstanceWithAuthenticationEventEntity(): void + { + $job = new Job(new Event(new State(StateArrays::FULL))); + + $this->assertInstanceOf(Event::class, $job->getPayload()); + } + + public function testPayloadMustBeAuthenticationEventOrThrow(): void + { + $payload = new class extends AbstractPayload { + }; + + $this->expectException(UnexpectedValueException::class); + + (new Job($payload)); + } + + public function testCanGetProperType(): void + { + $job = new Job(new Event(new State(StateArrays::FULL))); + + $this->assertSame(Job::class, $job->getType()); + } +} diff --git a/tests/src/Entities/Authentication/EventTest.php b/tests/src/Entities/Authentication/EventTest.php new file mode 100644 index 0000000000000000000000000000000000000000..58b063437fb3847f9b0d66bcf3876aa6c09b358a --- /dev/null +++ b/tests/src/Entities/Authentication/EventTest.php @@ -0,0 +1,32 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Entities\Authentication; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Test\Module\accounting\Constants\StateArrays; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\Authentication\Event + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State + * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class EventTest extends TestCase +{ + public function testCanGetState(): void + { + $dateTime = new \DateTimeImmutable(); + $authenticationEvent = new Event(new State(StateArrays::FULL), $dateTime); + + $this->assertInstanceOf(State::class, $authenticationEvent->getState()); + + $this->assertSame( + StateArrays::FULL['Source']['entityid'], + $authenticationEvent->getState()->getIdentityProviderEntityId() + ); + + $this->assertEquals($dateTime, $authenticationEvent->getHappenedAt()); + } +} diff --git a/tests/src/Entities/Authentication/StateTest.php b/tests/src/Entities/Authentication/StateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5be1a12aba53891e7236d765a45758532996f529 --- /dev/null +++ b/tests/src/Entities/Authentication/StateTest.php @@ -0,0 +1,188 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Entities\Authentication; + +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Test\Module\accounting\Constants\StateArrays; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\Authentication\State + * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class StateTest extends TestCase +{ + public function testCanInitializeValidState(): void + { + $state = new State(StateArrays::FULL); + + $this->assertSame($state->getIdentityProviderEntityId(), StateArrays::FULL['Source']['entityid']); + } + + public function testCanResolveIdpEntityId(): void + { + $stateArray = StateArrays::FULL; + $state = new State($stateArray); + $this->assertSame($state->getIdentityProviderEntityId(), StateArrays::FULL['IdPMetadata']['entityid']); + + $this->expectException(UnexpectedValueException::class); + unset($stateArray['IdPMetadata']['entityid']); + new State($stateArray); + } + + public function testCanResolveSpEntityId(): void + { + $stateArray = StateArrays::FULL; + $state = new State($stateArray); + $this->assertSame($state->getServiceProviderEntityId(), StateArrays::FULL['SPMetadata']['entityid']); + + $this->expectException(UnexpectedValueException::class); + unset($stateArray['SPMetadata']['entityid']); + new State($stateArray); + } + + public function testCanResolveAttributes(): void + { + $state = new State(StateArrays::FULL); + $this->assertSame($state->getAttributes(), StateArrays::FULL['Attributes']); + } + + public function testCanResolveAccountedClientIpAddress(): void + { + $stateArray = StateArrays::FULL; + + $state = new State($stateArray); + $this->assertNull($state->getClientIpAddress()); + + $sampleIp = '123.123.123.123'; + $stateArray[State::KEY_ACCOUNTING][State::ACCOUNTING_KEY_CLIENT_IP_ADDRESS] = $sampleIp; + + $state = new State($stateArray); + $this->assertSame($sampleIp, $state->getClientIpAddress()); + } + + public function testReturnsNullIfAuthnInstantNotPresent(): void + { + $stateArray = StateArrays::FULL; + + unset($stateArray['AuthnInstant']); + + $state = new State($stateArray); + + $this->assertNull($state->getAuthenticationInstant()); + } + + public function testThrowsOnMissingSourceEntityId(): void + { + $this->expectException(UnexpectedValueException::class); + + $stateArray = StateArrays::FULL; + + unset($stateArray['Source'], $stateArray['IdPMetadata']); + + /** @psalm-suppress UnusedMethodCall */ + (new State($stateArray)); + } + + public function testUseSpMetadataForEntityIdIfDestinationNotAvailable(): void + { + $stateArray = StateArrays::FULL; + + unset($stateArray['Destination']); + + $state = new State($stateArray); + + $this->assertSame($state->getServiceProviderEntityId(), StateArrays::FULL['SPMetadata']['entityid']); + } + + public function testThrowsOnMissingDestinationEntityId(): void + { + $this->expectException(UnexpectedValueException::class); + + $stateArray = StateArrays::FULL; + + unset($stateArray['Destination'], $stateArray['SPMetadata']); + + (new State($stateArray)); + } + + public function testThrowsOnInvalidAuthnInstantValue(): void + { + $this->expectException(UnexpectedValueException::class); + + $stateArray = StateArrays::FULL; + $stateArray['AuthnInstant'] = 'invalid'; + + new State($stateArray); + } + + public function testThrowsOnMissingAttributes(): void + { + $this->expectException(UnexpectedValueException::class); + + $stateArray = StateArrays::FULL; + + unset($stateArray['Attributes']); + + /** @psalm-suppress UnusedMethodCall */ + (new State($stateArray)); + } + + public function testCanGetAttributeValue(): void + { + $state = new State(StateArrays::FULL); + + $this->assertSame( + StateArrays::FULL['Attributes']['hrEduPersonUniqueID'][0], + $state->getAttributeValue('hrEduPersonUniqueID') + ); + + $this->assertNull($state->getAttributeValue('non-existent')); + } + + public function testCanResolveIdpMetadataArray(): void + { + // Metadata from 'IdPMetadata' + $sampleState = StateArrays::FULL; + $state = new State($sampleState); + $this->assertEquals($sampleState['IdPMetadata'], $state->getIdentityProviderMetadata()); + + // Fallback metadata from 'Source' + unset($sampleState['IdPMetadata']); + $state = new State($sampleState); + $this->assertEquals($sampleState['Source'], $state->getIdentityProviderMetadata()); + + // Throws on no IdP metadata + $this->expectException(UnexpectedValueException::class); + unset($sampleState['Source']); + new State($sampleState); + } + + public function testCanResolveSpMetadataArray(): void + { + // Metadata from 'IdPMetadata' + $sampleState = StateArrays::FULL; + $state = new State($sampleState); + $this->assertEquals($sampleState['SPMetadata'], $state->getServiceProviderMetadata()); + + // Fallback metadata from 'Destination' + unset($sampleState['SPMetadata']); + $state = new State($sampleState); + $this->assertEquals($sampleState['Destination'], $state->getServiceProviderMetadata()); + + // Throws on no SP metadata + $this->expectException(UnexpectedValueException::class); + unset($sampleState['Destination']); + new State($sampleState); + } + + public function testCanGetCreatedAt(): void + { + $state = new State(StateArrays::FULL); + $this->assertInstanceOf(\DateTimeImmutable::class, $state->getCreatedAt()); + } +} diff --git a/tests/src/Entities/Bases/AbstractJobTest.php b/tests/src/Entities/Bases/AbstractJobTest.php new file mode 100644 index 0000000000000000000000000000000000000000..22725b8079bf41a9070af20b1b4922af94b1270f --- /dev/null +++ b/tests/src/Entities/Bases/AbstractJobTest.php @@ -0,0 +1,40 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Entities\Bases; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event + */ +class AbstractJobTest extends TestCase +{ + protected AbstractPayload $payload; + + protected function setUp(): void + { + $this->payload = new class extends AbstractPayload { + }; + } + + public function testCanInitializeProperties(): void + { + $id = 1; + $createdAt = new \DateTimeImmutable(); + $job = new class ($this->payload, $id, $createdAt) extends AbstractJob { + public function getType(): string + { + return self::class; + } + }; + + $this->assertSame($id, $job->getId()); + $this->assertSame($createdAt, $job->getCreatedAt()); + $this->assertSame(get_class($job), $job->getType()); + $this->assertInstanceOf(AbstractPayload::class, $job->getPayload()); + } +} diff --git a/tests/src/Entities/Bases/AbstractProviderTest.php b/tests/src/Entities/Bases/AbstractProviderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f888d2384167a336bd5ff03c8a3b2dd8c12dcb0b --- /dev/null +++ b/tests/src/Entities/Bases/AbstractProviderTest.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Entities\Bases; + +use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\IdentityProvider; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider + * @uses \SimpleSAML\Module\accounting\Entities\IdentityProvider + */ +class AbstractProviderTest extends TestCase +{ + /** + * @var array + */ + protected array $metadata; + + public function setUp(): void + { + $this->metadata = [ + AbstractProvider::METADATA_KEY_ENTITY_ID => 'http//example.org/idp', + AbstractProvider::METADATA_KEY_NAME => [ + 'en' => 'Example service', + ], + AbstractProvider::METADATA_KEY_DESCRIPTION => [ + 'en' => 'Example description' + ], + ]; + } + + /** + * @psalm-suppress MixedArrayAccess + */ + public function testCanCreateInstance(): void + { + $identityProvider = new IdentityProvider($this->metadata); + + $this->assertSame($this->metadata, $identityProvider->getMetadata()); + $this->assertSame( + $this->metadata[AbstractProvider::METADATA_KEY_ENTITY_ID], + $identityProvider->getEntityId() + ); + $this->assertSame( + $this->metadata[AbstractProvider::METADATA_KEY_NAME]['en'], + $identityProvider->getName() + ); + $this->assertSame( + $this->metadata[AbstractProvider::METADATA_KEY_DESCRIPTION]['en'], + $identityProvider->getDescription() + ); + } + + public function testCanResolveNonLocalizedString(): void + { + $metadata = $this->metadata; + $metadata[AbstractProvider::METADATA_KEY_DESCRIPTION] = 'Non localized description.'; + + $identityProvider = new IdentityProvider($metadata); + + $this->assertSame($metadata[AbstractProvider::METADATA_KEY_DESCRIPTION], $identityProvider->getDescription()); + } + + public function testInvalidLocalizedDataResolvesToNull(): void + { + $metadata = $this->metadata; + $metadata[AbstractProvider::METADATA_KEY_DESCRIPTION] = false; + + $identityProvider = new IdentityProvider($metadata); + + $this->assertNull($identityProvider->getDescription()); + } + + public function testReturnsNullIfNameNotAvailable(): void + { + $metadata = $this->metadata; + unset($metadata[AbstractProvider::METADATA_KEY_NAME]); + + $identityProvider = new IdentityProvider($metadata); + $this->assertNull($identityProvider->getName()); + } + + public function testThrowsIfEntityIdNotAvailable(): void + { + $metadata = $this->metadata; + unset($metadata[AbstractProvider::METADATA_KEY_ENTITY_ID]); + + $this->expectException(UnexpectedValueException::class); + new IdentityProvider($metadata); + } +} diff --git a/tests/src/Entities/ConnectedServiceProvider/BagTest.php b/tests/src/Entities/ConnectedServiceProvider/BagTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3d62a773c429d5a84e8df4de9d4bb817e5ab712f --- /dev/null +++ b/tests/src/Entities/ConnectedServiceProvider/BagTest.php @@ -0,0 +1,23 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Entities\ConnectedServiceProvider; + +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag + */ +class BagTest extends TestCase +{ + public function testCanAddConnectedService(): void + { + $connectedServiceProvider = $this->createStub(ConnectedServiceProvider::class); + $bag = new Bag(); + + $this->assertEmpty($bag->getAll()); + $bag->addOrReplace($connectedServiceProvider); + $this->assertNotEmpty($bag->getAll()); + } +} diff --git a/tests/src/Entities/ConnectedServiceProviderTest.php b/tests/src/Entities/ConnectedServiceProviderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..186fb2e9a71b22c51e71de0be6ef6bb1b11bf406 --- /dev/null +++ b/tests/src/Entities/ConnectedServiceProviderTest.php @@ -0,0 +1,51 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Entities; + +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\ServiceProvider; +use SimpleSAML\Module\accounting\Entities\User; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider + */ +class ConnectedServiceProviderTest extends TestCase +{ + /** + * @var \PHPUnit\Framework\MockObject\Stub|ServiceProvider|ServiceProvider&\PHPUnit\Framework\MockObject\Stub + */ + protected $serviceProviderStub; + /** + * @var \PHPUnit\Framework\MockObject\Stub|User|User&\PHPUnit\Framework\MockObject\Stub + */ + protected $userStub; + protected \DateTimeImmutable $dateTime; + protected int $numberOfAuthentications; + + public function setUp(): void + { + $this->serviceProviderStub = $this->createStub(ServiceProvider::class); + $this->userStub = $this->createStub(User::class); + $this->dateTime = new \DateTimeImmutable(); + $this->numberOfAuthentications = 1; + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress PossiblyInvalidArgument */ + $connectedServiceProvider = new ConnectedServiceProvider( + $this->serviceProviderStub, + $this->numberOfAuthentications, + $this->dateTime, + $this->dateTime, + $this->userStub + ); + + $this->assertSame($this->serviceProviderStub, $connectedServiceProvider->getServiceProvider()); + $this->assertSame($this->numberOfAuthentications, $connectedServiceProvider->getNumberOfAuthentications()); + $this->assertSame($this->dateTime, $connectedServiceProvider->getFirstAuthenticationAt()); + $this->assertSame($this->dateTime, $connectedServiceProvider->getLastAuthenticationAt()); + $this->assertSame($this->userStub, $connectedServiceProvider->getUser()); + } +} diff --git a/tests/src/Entities/GenericJobTest.php b/tests/src/Entities/GenericJobTest.php new file mode 100644 index 0000000000000000000000000000000000000000..11aed48d0183f49e9204d63d734caa07a193322a --- /dev/null +++ b/tests/src/Entities/GenericJobTest.php @@ -0,0 +1,21 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Entities; + +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; +use SimpleSAML\Module\accounting\Entities\GenericJob; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\GenericJob + * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob + */ +class GenericJobTest extends TestCase +{ + public function testCanGetProperType(): void + { + $job = new GenericJob($this->createStub(AbstractPayload::class)); + + $this->assertSame(GenericJob::class, $job->getType()); + } +} diff --git a/tests/src/Entities/IdentityProviderTest.php b/tests/src/Entities/IdentityProviderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..401c926b2162f5901a991687dc4b2ba7094258d5 --- /dev/null +++ b/tests/src/Entities/IdentityProviderTest.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Entities; + +use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider; +use SimpleSAML\Module\accounting\Entities\IdentityProvider; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\IdentityProvider + * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider + */ +class IdentityProviderTest extends TestCase +{ + /** + * @var string[] + */ + protected array $metadata; + + public function setUp(): void + { + $this->metadata = [ + AbstractProvider::METADATA_KEY_ENTITY_ID => 'http//example.org/idp' + ]; + } + + public function testCanCreateInstance(): void + { + $identityProvider = new IdentityProvider($this->metadata); + $this->assertSame($this->metadata, $identityProvider->getMetadata()); + } +} diff --git a/tests/src/Entities/ServiceProviderTest.php b/tests/src/Entities/ServiceProviderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3c959231ab46acd593973adc08f81297e8aa2011 --- /dev/null +++ b/tests/src/Entities/ServiceProviderTest.php @@ -0,0 +1,31 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Entities; + +use SimpleSAML\Module\accounting\Entities\ServiceProvider; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\ServiceProvider + * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider + */ +class ServiceProviderTest extends TestCase +{ + /** + * @var string[] + */ + protected array $metadata; + + public function setUp(): void + { + $this->metadata = [ + 'entityid' => 'http//example.org' + ]; + } + + public function testCanCreateInstance(): void + { + $serviceProvider = new ServiceProvider($this->metadata); + $this->assertSame($this->metadata, $serviceProvider->getMetadata()); + } +} diff --git a/tests/src/Entities/UserTest.php b/tests/src/Entities/UserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..21199404ffe97212b410c4138b828daa4f2645fc --- /dev/null +++ b/tests/src/Entities/UserTest.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Entities; + +use SimpleSAML\Module\accounting\Entities\User; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Entities\User + */ +class UserTest extends TestCase +{ + /** + * @var string[][] + */ + protected array $attributes; + + protected function setUp(): void + { + $this->attributes = [ + 'uid' => ['test'], + ]; + } + + public function testCanCreateInstance(): void + { + $user = new User($this->attributes); + $this->assertSame($this->attributes, $user->getAttributes()); + } +} diff --git a/tests/src/Helpers/ArrayHelperTest.php b/tests/src/Helpers/ArrayHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..204f2886889d694fec9808dffafa0d30bfc05a44 --- /dev/null +++ b/tests/src/Helpers/ArrayHelperTest.php @@ -0,0 +1,31 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Helpers\ArrayHelper; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\ArrayHelper + */ +class ArrayHelperTest extends TestCase +{ + public function testCanRecursivelySortByKey(): void + { + $unsorted = [ + 'b' => [1 => 1, 0 => 0], + 'a' => [1 => 1, 0 => 0], + ]; + + $sorted = [ + 'a' => [0 => 0, 1 => 1], + 'b' => [0 => 0, 1 => 1], + ]; + + $this->assertNotSame($unsorted, $sorted); + + (new ArrayHelper())->recursivelySortByKey($unsorted); + + $this->assertSame($unsorted, $sorted); + } +} diff --git a/tests/src/Helpers/AttributesHelperTest.php b/tests/src/Helpers/AttributesHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8400b1e042144da64a695aba893e89a5a4f65fde --- /dev/null +++ b/tests/src/Helpers/AttributesHelperTest.php @@ -0,0 +1,54 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Helpers\AttributesHelper; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\AttributesHelper + * @uses \SimpleSAML\Module\accounting\ModuleConfiguration + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class AttributesHelperTest extends TestCase +{ + /** + * @var string $sspBaseDir Simulated SSP base directory. + */ + protected string $sspBaseDir; + + /** + * @var string[] + */ + protected array $mapFiles; + protected HelpersManager $helpersManager; + + protected function setUp(): void + { + $this->sspBaseDir = (new ModuleConfiguration())->getModuleRootDirectory() . DIRECTORY_SEPARATOR . 'tests' . + DIRECTORY_SEPARATOR; + + $this->mapFiles = ['test.php', 'test2.php']; + + $this->helpersManager = new HelpersManager(); + } + + public function testCanLoadAttributeMaps(): void + { + $fullAttributeMap = $this->helpersManager->getAttributesHelper() + ->getMergedAttributeMapForFiles($this->sspBaseDir, $this->mapFiles); + + $this->assertArrayHasKey('mobile', $fullAttributeMap); + $this->assertArrayHasKey('phone', $fullAttributeMap); + } + + public function testIgnoresNonExistentMaps(): void + { + $fullAttributeMap = $this->helpersManager->getAttributesHelper() + ->getMergedAttributeMapForFiles($this->sspBaseDir, ['invalid.php']); + + $this->assertEmpty($fullAttributeMap); + } +} diff --git a/tests/src/Helpers/DateTimeHelperTest.php b/tests/src/Helpers/DateTimeHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ad82ee7fc20d4ff429a104f1d4bb097246e68a1f --- /dev/null +++ b/tests/src/Helpers/DateTimeHelperTest.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Helpers\DateTimeHelper; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\DateTimeHelper + */ +class DateTimeHelperTest extends TestCase +{ + public function testCanConvertDateIntervalToSeconds(): void + { + $interval = new \DateInterval('PT10S'); + + $this->assertSame(10, (new DateTimeHelper())->convertDateIntervalToSeconds($interval)); + } + + public function testMinimumIntervalIsOneSecond(): void + { + $interval = \DateInterval::createFromDateString('-10 seconds'); // Negative interval + + $this->assertSame(1, (new DateTimeHelper())->convertDateIntervalToSeconds($interval)); + } +} diff --git a/tests/src/Helpers/EnvironmentHelperTest.php b/tests/src/Helpers/EnvironmentHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c6e95ef5b24d04ec5bdba1f5afb3a93fdc62aaac --- /dev/null +++ b/tests/src/Helpers/EnvironmentHelperTest.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Helpers\EnvironmentHelper; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\EnvironmentHelper + */ +class EnvironmentHelperTest extends TestCase +{ + public function testConfirmIsCli(): void + { + $this->assertTrue((new EnvironmentHelper())->isCli()); + } +} diff --git a/tests/src/Helpers/FilesystemHelperTest.php b/tests/src/Helpers/FilesystemHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e6e76ab8221e7cd97cd931f465a2acfb3458cda5 --- /dev/null +++ b/tests/src/Helpers/FilesystemHelperTest.php @@ -0,0 +1,31 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Exceptions\InvalidValueException; +use SimpleSAML\Module\accounting\Helpers\FilesystemHelper; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\FilesystemHelper + */ +class FilesystemHelperTest extends TestCase +{ + public function testCanGetRealPath(): void + { + $path = __DIR__ . DIRECTORY_SEPARATOR . '..'; + + $realPath = (new FilesystemHelper())->getRealPath($path); + + $this->assertSame(dirname(__DIR__), $realPath); + } + + public function testGetRealPathThrowsOnInvalidPaths(): void + { + $path = __DIR__ . DIRECTORY_SEPARATOR . 'invalid'; + + $this->expectException(InvalidValueException::class); + + (new FilesystemHelper())->getRealPath($path); + } +} diff --git a/tests/src/Helpers/HashHelperTest.php b/tests/src/Helpers/HashHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e0e620a9b35d3cf7e8fb709388281d9370365269 --- /dev/null +++ b/tests/src/Helpers/HashHelperTest.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Helpers\ArrayHelper; +use SimpleSAML\Module\accounting\Helpers\HashHelper; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Services\HelpersManager; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\HashHelper + * @uses \SimpleSAML\Module\accounting\Helpers\ArrayHelper + */ +class HashHelperTest extends TestCase +{ + protected HashHelper $hashHelper; + + protected string $data; + protected string $dataSha256; + /** + * @var \int[][] + */ + protected array $unsortedArrayData; + protected string $unsortedArraySha256; + /** + * @var \int[][] + */ + protected array $sortedArrayData; + protected string $sortedArrayDataSha256; + + protected function setUp(): void + { + $this->hashHelper = new HashHelper(new ArrayHelper()); + + $this->data = 'test'; + $this->dataSha256 = '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'; + $this->unsortedArrayData = ['b' => [1 => 1, 0 => 0,], 'a' => [1 => 1, 0 => 0,],]; + $this->unsortedArraySha256 = 'c467191ddddcaa5a6cc3bac28c1fd0557eb9390dbb079195a2ae70c49ce62da7'; + $this->sortedArrayData = ['a' => [0 => 0, 1 => 1,], 'b' => [0 => 0, 1 => 1,],]; + $this->sortedArrayDataSha256 = 'c467191ddddcaa5a6cc3bac28c1fd0557eb9390dbb079195a2ae70c49ce62da7'; + } + + public function testCanGetSha256ForString(): void + { + $this->assertSame($this->dataSha256, $this->hashHelper->getSha256($this->data)); + } + + public function testCanGetSha256ForArray(): void + { + // Arrays are sorted before the hash is calculated, so the value must be the same. + $this->assertSame($this->unsortedArraySha256, $this->sortedArrayDataSha256); + $this->assertSame( + $this->unsortedArraySha256, + $this->hashHelper->getSha256ForArray($this->unsortedArrayData) + ); + $this->assertSame( + $this->sortedArrayDataSha256, + $this->hashHelper->getSha256ForArray($this->sortedArrayData) + ); + } +} diff --git a/tests/src/Helpers/InstanceBuilderUsingModuleConfigurationHelperTest.php b/tests/src/Helpers/InstanceBuilderUsingModuleConfigurationHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..63345edb3e418dadcc07be93790d4355498c8e22 --- /dev/null +++ b/tests/src/Helpers/InstanceBuilderUsingModuleConfigurationHelperTest.php @@ -0,0 +1,65 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper + */ +class InstanceBuilderUsingModuleConfigurationHelperTest extends TestCase +{ + protected BuildableUsingModuleConfigurationInterface $stub; + /** @var class-string */ + protected string $stubClass; + protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub; + protected \PHPUnit\Framework\MockObject\Stub $loggerStub; + + protected function setUp(): void + { + $this->stub = new class () implements BuildableUsingModuleConfigurationInterface { + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger + ): BuildableUsingModuleConfigurationInterface { + return new self(); + } + }; + + $this->stubClass = get_class($this->stub); + + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->loggerStub = $this->createStub(LoggerInterface::class); + } + + public function testCanBuildClassInstance(): void + { + /** @psalm-suppress InvalidArgument */ + $this->assertInstanceOf( + BuildableUsingModuleConfigurationInterface::class, + (new InstanceBuilderUsingModuleConfigurationHelper())->build( + $this->stubClass, + $this->moduleConfigurationStub, + $this->loggerStub + ) + ); + } + + public function testThrowsForInvalidClass(): void + { + $this->expectException(Exception::class); + + /** @psalm-suppress InvalidArgument */ + (new InstanceBuilderUsingModuleConfigurationHelper())->build( + ModuleConfiguration::class, // Sample class which is not buildable. + $this->moduleConfigurationStub, + $this->loggerStub + ); + } +} diff --git a/tests/src/Helpers/ModuleRoutesHelperTest.php b/tests/src/Helpers/ModuleRoutesHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..aa07c47da76c05a25980c69bbff698211f5c66b0 --- /dev/null +++ b/tests/src/Helpers/ModuleRoutesHelperTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Helpers\ModuleRoutesHelper; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Utils\HTTP; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\ModuleRoutesHelper + */ +class ModuleRoutesHelperTest extends TestCase +{ + protected const BASE_URL = 'https://example.org/ssp/'; + /** + * @var \PHPUnit\Framework\MockObject\Stub|HTTP + */ + protected $sspHttpUtilsStub; + protected string $moduleUrl; + + protected function setUp(): void + { + $this->sspHttpUtilsStub = $this->createStub(HTTP::class); + $this->sspHttpUtilsStub->method('getBaseURL')->willReturn(self::BASE_URL); + + $this->moduleUrl = self::BASE_URL . 'module.php/' . ModuleConfiguration::MODULE_NAME; + } + + public function testCanGetModuleUrl(): void + { + $path = 'sample-path'; + $moduleUrlWithPath = $this->moduleUrl . '/' . $path; + + /** @psalm-suppress PossiblyInvalidArgument */ + $moduleRoutesHelper = new ModuleRoutesHelper($this->sspHttpUtilsStub); + + $this->assertSame($moduleUrlWithPath, $moduleRoutesHelper->getUrl($path)); + } + + public function testCanCallMethodToAddParamsToModuleUrl(): void + { + $path = 'sample-path'; + $params = ['sample' => 'param']; + $fullUrl = 'full-url-with-sample-param'; + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->sspHttpUtilsStub->method('addURLParameters')->willReturn($fullUrl); + /** @psalm-suppress PossiblyInvalidArgument */ + $moduleRoutesHelper = new ModuleRoutesHelper($this->sspHttpUtilsStub); + + $this->assertSame($fullUrl, $moduleRoutesHelper->getUrl($path, $params)); + } +} diff --git a/tests/src/Helpers/NetworkHelperTest.php b/tests/src/Helpers/NetworkHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..98cfed24aa0f38753101dcc71ae7f9438eda739e --- /dev/null +++ b/tests/src/Helpers/NetworkHelperTest.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Helpers\NetworkHelper; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\NetworkHelper + */ +class NetworkHelperTest extends TestCase +{ + protected string $ipAddress; + + protected function setUp(): void + { + $this->ipAddress = '123.123.123.123'; + } + + public function testCanGetIpFromParameter(): void + { + $this->assertSame($this->ipAddress, (new NetworkHelper())->resolveClientIpAddress($this->ipAddress)); + } + + public function testReturnsNullForInvalidIp(): void + { + $this->assertNull((new NetworkHelper())->resolveClientIpAddress('invalid')); + } + + public function testReturnsNullForNonExistentIp(): void + { + $this->assertNull((new NetworkHelper())->resolveClientIpAddress()); + } + + /** + * @backupGlobals enabled + */ + public function testCanResolveIpAddress(): void + { + global $_SERVER; + + $_SERVER['REMOTE_ADDR'] = $this->ipAddress; + + $this->assertSame($this->ipAddress, (new NetworkHelper())->resolveClientIpAddress()); + } +} diff --git a/tests/src/Helpers/RandomHelperTest.php b/tests/src/Helpers/RandomHelperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..538b3136906ca88b5744e959d252f5f57e665ab2 --- /dev/null +++ b/tests/src/Helpers/RandomHelperTest.php @@ -0,0 +1,17 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Helpers; + +use SimpleSAML\Module\accounting\Helpers\RandomHelper; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Helpers\RandomHelper + */ +class RandomHelperTest extends TestCase +{ + public function testCanGetRandomInt(): void + { + $this->assertIsInt((new RandomHelper())->getRandomInt()); + } +} diff --git a/tests/src/ModuleConfigurationTest.php b/tests/src/ModuleConfigurationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..50bc22290cc94da2e0769762d596448b3f460254 --- /dev/null +++ b/tests/src/ModuleConfigurationTest.php @@ -0,0 +1,352 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Configuration; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Stores; +use SimpleSAML\Module\accounting\Trackers; + +/** + * @covers \SimpleSAML\Module\accounting\ModuleConfiguration + */ +class ModuleConfigurationTest extends TestCase +{ + protected ModuleConfiguration $moduleConfiguration; + + protected function setUp(): void + { + parent::setUp(); + // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml). + $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php'); + } + + public function testCanGetUnderlyingConfigurationInstance(): void + { + $this->assertInstanceOf(Configuration::class, $this->moduleConfiguration->getConfiguration()); + } + + public function testThrowExceptionsIfInvalidOptionIsSupplied(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->moduleConfiguration->get('invalid'); + } + + public function testCanGetValidOption(): void + { + $this->assertIsString($this->moduleConfiguration->get(ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME)); + } + + public function testCanGetUserIdAttributeName(): void + { + $this->assertIsString($this->moduleConfiguration->getUserIdAttributeName()); + } + + public function testCanGetDefaultAuthenticationSource(): void + { + $this->assertIsString($this->moduleConfiguration->getDefaultAuthenticationSource()); + } + + public function testCanGetJobsStoreClass(): void + { + $this->assertTrue( + is_subclass_of($this->moduleConfiguration->getJobsStoreClass(), Stores\Interfaces\JobsStoreInterface::class) + ); + } + + public function testThrowsForInvalidConfig(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE => 'invalid', + ] + ); + } + + public function testThrowsForInvalidJobsStore(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE => + ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS, + ModuleConfiguration::OPTION_JOBS_STORE => 'invalid', + ] + ); + } + + public function testProperConnectionKeyIsReturned(): void + { + $this->assertSame( + 'doctrine_dbal_pdo_sqlite', + $this->moduleConfiguration->getClassConnectionKey(Stores\Jobs\DoctrineDbal\Store::class) + ); + } + + public function testCanGetSlaveConnectionKey(): void + { + $this->assertSame( + 'doctrine_dbal_pdo_sqlite_slave', + $this->moduleConfiguration->getClassConnectionKey( + Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class, + ModuleConfiguration\ConnectionType::SLAVE + ) + ); + } + + public function testThrowsForNonStringAndNonArrayConnectionKey(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [ + 'invalid-object-value' => new \stdClass(), + ] + ] + ); + } + + public function testThrowsForNonMasterInArrayConnection(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [ + 'invalid-array-value' => [ + 'no-master-key' => 'invalid', + ], + ] + ] + ); + } + + public function testThrowsForInvalidConnectiontype(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->moduleConfiguration->getClassConnectionKey( + Stores\Jobs\DoctrineDbal\Store::class, + 'invalid' + ); + } + + public function testInvalidConnectionKeyThrows(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->moduleConfiguration->getClassConnectionParameters('invalid'); + } + + public function testCanGetDefinedConnections(): void + { + $this->assertArrayHasKey( + 'doctrine_dbal_pdo_sqlite', + $this->moduleConfiguration->getConnectionsAndParameters() + ); + } + + public function testCanGetParametersForSpecificConnection(): void + { + $this->assertIsArray($this->moduleConfiguration->getConnectionParameters('doctrine_dbal_pdo_sqlite')); + } + + public function testGettingSettingsForInvalidConnectionThrows(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->moduleConfiguration->getConnectionParameters('invalid'); + } + + public function testCanGetModuleSourceDirectory(): void + { + $this->assertSame( + dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'src', + $this->moduleConfiguration->getModuleSourceDirectory() + ); + } + + public function testCanGetModuleRootDirectory(): void + { + $this->assertSame( + dirname(__DIR__, 2), + $this->moduleConfiguration->getModuleRootDirectory() + ); + } + + public function testCanGetCronTagForJobRunner(): void + { + $this->assertSame( + 'accounting_job_runner', + $this->moduleConfiguration->getCronTagForJobRunner() + ); + } + + public function testCanGetJobRunnerMaximumExecutionTime(): void + { + $this->assertNull($this->moduleConfiguration->getJobRunnerMaximumExecutionTime()); + } + + public function testThrowsForNonStringJobRunnerMaximumExecutionTime(): void + { + $moduleConfiguration = new ModuleConfiguration( + null, + [ModuleConfiguration::OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME => false] + ); + + $this->expectException(InvalidConfigurationException::class); + + $moduleConfiguration->getJobRunnerMaximumExecutionTime(); + } + + public function testThrowsForInvalidStringJobRunnerMaximumExecutionTime(): void + { + $moduleConfiguration = new ModuleConfiguration( + null, + [ModuleConfiguration::OPTION_JOB_RUNNER_MAXIMUM_EXECUTION_TIME => 'invalid'] + ); + + + $this->expectException(InvalidConfigurationException::class); + + $moduleConfiguration->getJobRunnerMaximumExecutionTime(); + } + + public function testCanGetJobRunnerShouldPauseAfterNumberOfJobsProcessed(): void + { + $this->assertSame(10, $this->moduleConfiguration->getJobRunnerShouldPauseAfterNumberOfJobsProcessed()); + } + + public function testCanGetNullForJobRunnerShouldPauseAfterNumberOfJobsProcessed(): void + { + $moduleConfiguration = new ModuleConfiguration( + null, + [ModuleConfiguration::OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED => null] + ); + + $this->assertNull($moduleConfiguration->getJobRunnerShouldPauseAfterNumberOfJobsProcessed()); + } + + public function testThrowsForNonIntegerJobRunnerShouldPauseAfterNumberOfJobsProcessed(): void + { + $moduleConfiguration = new ModuleConfiguration( + null, + [ModuleConfiguration::OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED => false] + ); + + $this->expectException(InvalidConfigurationException::class); + + $moduleConfiguration->getJobRunnerShouldPauseAfterNumberOfJobsProcessed(); + } + + public function testThrowsForNegativeIntegerJobRunnerShouldPauseAfterNumberOfJobsProcessed(): void + { + $moduleConfiguration = new ModuleConfiguration( + null, + [ModuleConfiguration::OPTION_JOB_RUNNER_SHOULD_PAUSE_AFTER_NUMBER_OF_JOBS_PROCESSED => -1] + ); + + $this->expectException(InvalidConfigurationException::class); + + $moduleConfiguration->getJobRunnerShouldPauseAfterNumberOfJobsProcessed(); + } + + public function testThrowsOnInvalidCronTag(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE => + ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS, + ModuleConfiguration::OPTION_CRON_TAG_FOR_JOB_RUNNER => -1 + ] + ); + } + + public function testThrowsOnInvalidDefaultDataTrackerAndProvider(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER => 'invalid' + ] + ); + } + + public function testThrowsOnInvalidAdditionalTrackers(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => ['invalid'] + ] + ); + } + + public function testThrowsOnNonStringAdditionalTracker(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [-1] + ] + ); + } + + public function testThrowsWhenClassHasNoConnectionParametersSet(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [] + ] + ); + } + + public function testThrowsForInvalidTrackerDataRetentionPolicy(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_TRACKER_DATA_RETENTION_POLICY => 'invalid' + ] + ); + } + + public function testThrowsForInvalidCronTagForTrackerDataRetentionPolicy(): void + { + $this->expectException(InvalidConfigurationException::class); + + new ModuleConfiguration( + null, + [ + ModuleConfiguration::OPTION_TRACKER_DATA_RETENTION_POLICY => 'P1D', + ModuleConfiguration::OPTION_CRON_TAG_FOR_TRACKER_DATA_RETENTION_POLICY => false, + ] + ); + } +} diff --git a/tests/src/Providers/Builders/AuthenticationDataProviderBuilderTest.php b/tests/src/Providers/Builders/AuthenticationDataProviderBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..497fd3a69221b9222a367ce3c6e70f7acaa03ad2 --- /dev/null +++ b/tests/src/Providers/Builders/AuthenticationDataProviderBuilderTest.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Providers\Builders; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Providers\Builders\AuthenticationDataProviderBuilder; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Providers\Builders\AuthenticationDataProviderBuilder + * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper + * @uses \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore + * @uses \SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store + * @uses \SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * @uses \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + */ +class AuthenticationDataProviderBuilderTest extends TestCase +{ + protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub; + + protected \PHPUnit\Framework\MockObject\Stub $loggerStub; + protected HelpersManager $helpersManager; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $connectionParams = ConnectionParameters::DBAL_SQLITE_MEMORY; + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn($connectionParams); + + $this->loggerStub = $this->createStub(LoggerInterface::class); + $this->helpersManager = new HelpersManager(); + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress InvalidArgument */ + $this->assertInstanceOf( + AuthenticationDataProviderBuilder::class, + new AuthenticationDataProviderBuilder( + $this->moduleConfigurationStub, + $this->loggerStub, + $this->helpersManager + ) + ); + } + + public function testCanBuildDataProvider(): void + { + /** @psalm-suppress InvalidArgument */ + $builder = new AuthenticationDataProviderBuilder( + $this->moduleConfigurationStub, + $this->loggerStub, + $this->helpersManager + ); + + $this->assertInstanceOf(Tracker::class, $builder->build(Tracker::class)); + } + + public function testThrowsForInvalidClass(): void + { + $this->expectException(Exception::class); + + /** @psalm-suppress InvalidArgument */ + (new AuthenticationDataProviderBuilder( + $this->moduleConfigurationStub, + $this->loggerStub, + $this->helpersManager + ))->build('invalid'); + } +} diff --git a/tests/src/Services/HelpersManagerTest.php b/tests/src/Services/HelpersManagerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a199731970637389a04830557f170b4805735799 --- /dev/null +++ b/tests/src/Services/HelpersManagerTest.php @@ -0,0 +1,43 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Services; + +use SimpleSAML\Module\accounting\Helpers\ArrayHelper; +use SimpleSAML\Module\accounting\Helpers\AttributesHelper; +use SimpleSAML\Module\accounting\Helpers\DateTimeHelper; +use SimpleSAML\Module\accounting\Helpers\EnvironmentHelper; +use SimpleSAML\Module\accounting\Helpers\FilesystemHelper; +use SimpleSAML\Module\accounting\Helpers\HashHelper; +use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper; +use SimpleSAML\Module\accounting\Helpers\ModuleRoutesHelper; +use SimpleSAML\Module\accounting\Helpers\NetworkHelper; +use SimpleSAML\Module\accounting\Helpers\RandomHelper; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Services\HelpersManager + * @uses \SimpleSAML\Module\accounting\Helpers\ModuleRoutesHelper + * @uses \SimpleSAML\Module\accounting\Helpers\HashHelper + */ +class HelpersManagerTest extends TestCase +{ + public function testCanGetHelperInstances(): void + { + $helpersManager = new HelpersManager(); + + $this->assertInstanceOf(ArrayHelper::class, $helpersManager->getArrayHelper()); + $this->assertInstanceOf(AttributesHelper::class, $helpersManager->getAttributesHelper()); + $this->assertInstanceOf(DateTimeHelper::class, $helpersManager->getDateTimeHelper()); + $this->assertInstanceOf(EnvironmentHelper::class, $helpersManager->getEnvironmentHelper()); + $this->assertInstanceOf(FilesystemHelper::class, $helpersManager->getFilesystemHelper()); + $this->assertInstanceOf(HashHelper::class, $helpersManager->getHashHelper()); + $this->assertInstanceOf( + InstanceBuilderUsingModuleConfigurationHelper::class, + $helpersManager->getInstanceBuilderUsingModuleConfigurationHelper() + ); + $this->assertInstanceOf(NetworkHelper::class, $helpersManager->getNetworkHelper()); + $this->assertInstanceOf(RandomHelper::class, $helpersManager->getRandomHelper()); + $this->assertInstanceOf(ModuleRoutesHelper::class, $helpersManager->getModuleRoutesHelper()); + } +} diff --git a/tests/src/Services/JobRunner/RateLimiterTest.php b/tests/src/Services/JobRunner/RateLimiterTest.php new file mode 100644 index 0000000000000000000000000000000000000000..19a98c64aa60f6ef32a6e0bf6a4c8855c3be0762 --- /dev/null +++ b/tests/src/Services/JobRunner/RateLimiterTest.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Services\JobRunner; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Services\JobRunner\RateLimiter; + +/** + * @covers \SimpleSAML\Module\accounting\Services\JobRunner\RateLimiter + * @uses \SimpleSAML\Module\accounting\Helpers\DateTimeHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class RateLimiterTest extends TestCase +{ + protected function setUp(): void + { + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(RateLimiter::class, new RateLimiter()); + } + + public function testCanDoPause(): void + { + $rateLimiter = new RateLimiter(); + $startTimeInSeconds = (new \DateTimeImmutable())->getTimestamp(); + $rateLimiter->doPause(); + $endTimeInSeconds = (new \DateTimeImmutable())->getTimestamp(); + + $this->assertTrue(($endTimeInSeconds - $startTimeInSeconds) >= 1); + } + + public function testCanSetMaxPause(): void + { + $rateLimiter = new RateLimiter(new \DateInterval('PT1S')); + $this->assertSame(1, $rateLimiter->getMaxPauseInSeconds()); + $splitSecondInterval = \DateInterval::createFromDateString('10000 microsecond'); // 10 milliseconds + $rateLimiter = new RateLimiter($splitSecondInterval); + $this->assertSame(1, $rateLimiter->getMaxPauseInSeconds()); + $rateLimiter->doPause(); + } + + public function testCanDoBackoffPause(): void + { + $rateLimiter = new RateLimiter(); + $startTimeInSeconds = (new \DateTimeImmutable())->getTimestamp(); + $rateLimiter->doBackoffPause(); + $endTimeInSeconds = (new \DateTimeImmutable())->getTimestamp(); + $this->assertTrue(($endTimeInSeconds - $startTimeInSeconds) >= 1); + $this->assertTrue($rateLimiter->getCurrentBackoffPauseInSeconds() > 1); + $rateLimiter->resetBackoffPause(); + $this->assertTrue($rateLimiter->getCurrentBackoffPauseInSeconds() === 1); + } + + public function testCanSetMaxBackoffPause(): void + { + $rateLimiter = new RateLimiter(null, new \DateInterval('PT1S')); + $this->assertSame(1, $rateLimiter->getMaxBackoffPauseInSeconds()); + $splitSecondInterval = \DateInterval::createFromDateString('10000 microsecond'); // 10 milliseconds + $rateLimiter = new \SimpleSAML\Module\accounting\Services\JobRunner\RateLimiter(null, $splitSecondInterval); + $this->assertSame(1, $rateLimiter->getMaxBackoffPauseInSeconds()); + $rateLimiter->doBackoffPause(); + } +} diff --git a/tests/src/Services/JobRunner/StateTest.php b/tests/src/Services/JobRunner/StateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..533030e6ec26da4c65dbfdc67239401e21b815fd --- /dev/null +++ b/tests/src/Services/JobRunner/StateTest.php @@ -0,0 +1,117 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Services\JobRunner; + +use SimpleSAML\Module\accounting\Services\JobRunner\State; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Services\JobRunner\State + */ +class StateTest extends TestCase +{ + protected int $jobRunnerId; + + protected function setUp(): void + { + $this->jobRunnerId = 1; + } + + public function testCanCreateInstance(): void + { + $startedAt = $updatedAt = new \DateTimeImmutable(); + + $state = new State($this->jobRunnerId); + $this->assertInstanceOf(State::class, $state); + $this->assertSame($this->jobRunnerId, $state->getJobRunnerId()); + + $state = new State($this->jobRunnerId, $startedAt, null); + $this->assertInstanceOf(State::class, $state); + + $state = new State($this->jobRunnerId, $startedAt, $updatedAt); + $this->assertInstanceOf(State::class, $state); + + $state = new State($this->jobRunnerId, $startedAt, $updatedAt, 1000); + $this->assertInstanceOf(State::class, $state); + } + + public function testCanWorkWithTimestamps(): void + { + $startedAt = $updatedAt = $endedAt = new \DateTimeImmutable(); + + $state = new State($this->jobRunnerId); + $this->assertNull($state->getStartedAt()); + $this->assertInstanceOf(\DateTimeImmutable::class, $state->getUpdatedAt()); + $this->assertNull($state->getEndedAt()); + + $this->assertTrue($state->setStartedAt($startedAt)); + $this->assertTrue($state->hasRunStarted()); + $state->setUpdatedAt($updatedAt); + $this->assertTrue($state->setEndedAt($endedAt)); + + $this->assertSame($startedAt, $state->getStartedAt()); + $this->assertSame($updatedAt, $state->getUpdatedAt()); + $this->assertSame($endedAt, $state->getEndedAt()); + + $this->assertFalse($state->setStartedAt($startedAt)); + $this->assertFalse($state->setEndedAt($endedAt)); + } + + public function testCanCountProcessedJobs(): void + { + $state = new State($this->jobRunnerId); + + $this->assertSame(0, $state->getTotalJobsProcessed()); + $state->incrementSuccessfulJobsProcessed(); + + $this->assertSame(1, $state->getTotalJobsProcessed()); + $this->assertSame(1, $state->getSuccessfulJobsProcessed()); + $this->assertSame(0, $state->getFailedJobsProcessed()); + + $state->incrementFailedJobsProcessed(); + + $this->assertSame(2, $state->getTotalJobsProcessed()); + $this->assertSame(1, $state->getSuccessfulJobsProcessed()); + $this->assertSame(1, $state->getFailedJobsProcessed()); + } + + public function testCanCheckIfStateIsStale(): void + { + $state = new State($this->jobRunnerId); + $freshnessDuration = new \DateInterval('PT5M'); + + $this->assertFalse($state->isStale($freshnessDuration)); + + $dateTimeInHistory = new \DateTimeImmutable('-9 minutes'); + $state->setUpdatedAt($dateTimeInHistory); + + $this->assertTrue($state->isStale($freshnessDuration)); + } + + public function testCanWorkWithStatusMessages(): void + { + $state = new State($this->jobRunnerId, null, null, 2); + $this->assertEmpty($state->getStatusMessages()); + $this->assertNull($state->getLastStatusMessage()); + + $state->addStatusMessage('test'); + $this->assertSame(1, count($state->getStatusMessages())); + $this->assertSame('test', $state->getLastStatusMessage()); + $state->addStatusMessage('test2'); + $this->assertSame('test2', $state->getLastStatusMessage()); + $this->assertSame(2, count($state->getStatusMessages())); + $state->addStatusMessage('test3'); + $this->assertSame('test3', $state->getLastStatusMessage()); + $this->assertSame(2, count($state->getStatusMessages())); + $this->assertSame('test3', $state->getLastStatusMessage()); + } + + public function testCanSetGracefulInterruptInitiatedFlag(): void + { + $state = new State($this->jobRunnerId); + + $this->assertFalse($state->getIsGracefulInterruptInitiated()); + $state->setIsGracefulInterruptInitiated(true); + $this->assertTrue($state->getIsGracefulInterruptInitiated()); + } +} diff --git a/tests/src/Services/JobRunnerTest.php b/tests/src/Services/JobRunnerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ceddecc0c39a2ba53366c228814bb14116268c2a --- /dev/null +++ b/tests/src/Services/JobRunnerTest.php @@ -0,0 +1,1085 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Services; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; +use Psr\Log\LoggerInterface; +use Psr\SimpleCache\CacheInterface; +use SimpleSAML\Configuration; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; +use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\Helpers\DateTimeHelper; +use SimpleSAML\Module\accounting\Helpers\EnvironmentHelper; +use SimpleSAML\Module\accounting\Helpers\RandomHelper; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Services\JobRunner; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder; +use SimpleSAML\Module\accounting\Stores\Interfaces\JobsStoreInterface; +use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder; +use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface; + +/** + * @covers \SimpleSAML\Module\accounting\Services\JobRunner + * + * @psalm-suppress all + */ +class JobRunnerTest extends TestCase +{ + /** + * @var Stub|ModuleConfiguration + */ + protected $moduleConfigurationStub; + /** + * @var Stub|Configuration + */ + protected $sspConfigurationStub; + /** + * @var MockObject|LoggerInterface + */ + protected $loggerMock; + /** + * @var MockObject|CacheInterface + */ + protected $cacheMock; + /** + * @var Stub|JobRunner\State + */ + protected $stateStub; + /** + * @var Stub|JobRunner\RateLimiter + */ + protected $rateLimiterMock; + /** + * @var Stub|AuthenticationDataTrackerBuilder + */ + protected $authenticationDataTrackerBuilderStub; + /** + * @var MockObject|AuthenticationDataTrackerInterface + */ + protected $authenticationDataTrackerMock; + /** + * @var Stub|JobsStoreBuilder + */ + protected $jobsStoreBuilderStub; + /** + * @var Stub|RandomHelper + */ + protected $randomHelperStub; + /** + * @var Stub|EnvironmentHelper + */ + protected $environmentHelperStub; + /** + * @var Stub|DateTimeHelper + */ + protected $dateTimeHelperStub; + /** + * @var Stub|HelpersManager + */ + protected $helpersManagerStub; + /** + * @var Stub|JobsStoreInterface + */ + protected $jobsStoreMock; + /** + * @var Stub|JobInterface + */ + protected $jobStub; + /** + * @var Stub|AbstractPayload + */ + protected $payloadStub; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->sspConfigurationStub = $this->createStub(Configuration::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->authenticationDataTrackerBuilderStub = $this->createStub(AuthenticationDataTrackerBuilder::class); + $this->authenticationDataTrackerMock = $this->createMock(AuthenticationDataTrackerInterface::class); + $this->jobsStoreBuilderStub = $this->createStub(JobsStoreBuilder::class); + $this->cacheMock = $this->createMock(CacheInterface::class); + $this->stateStub = $this->createStub(JobRunner\State::class); + $this->rateLimiterMock = $this->createMock(JobRunner\RateLimiter::class); + $this->randomHelperStub = $this->createStub(RandomHelper::class); + $this->environmentHelperStub = $this->createStub(EnvironmentHelper::class); + $this->dateTimeHelperStub = $this->createStub(DateTimeHelper::class); + $this->helpersManagerStub = $this->createStub(HelpersManager::class); + $this->jobsStoreMock = $this->createMock(JobsStoreInterface::class); + $this->jobStub = $this->createStub(JobInterface::class); + $this->payloadStub = $this->createStub(Event::class); + } + + public function testCanCreateInstance(): void + { + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->assertInstanceOf( + JobRunner::class, + new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ) + ); + } + + public function testPreRunValidationFailsForSameJobRunnerId(): void + { + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->cacheMock->method('get')->willReturn($this->stateStub); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('error') + ->with('Job runner ID in cached state same as new ID.'); + $this->loggerMock->expects($this->atLeast(2))->method('warning') + ->withConsecutive( + [$this->stringContains('Pre-run state validation failed. Clearing cached state and continuing.')], + [$this->stringContains('Job runner called, however accounting mode is not')] + ); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testPreRunValidationFailsForStaleState(): void + { + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(321); + $this->stateStub->method('isStale')->willReturn(true); + $this->cacheMock->method('get')->willReturn($this->stateStub); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->atLeast(3))->method('warning') + ->withConsecutive( + [$this->stringContains('Stale state encountered.')], + [$this->stringContains('Pre-run state validation failed. Clearing cached state and continuing.')], + [$this->stringContains('Job runner called, however accounting mode is not')] + ); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testPreRunValidationPassesWhenStateIsNull(): void + { + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->cacheMock->method('get')->willReturn(null); + + $this->cacheMock->expects($this->never())->method('delete'); + + $this->loggerMock->expects($this->atLeast(1))->method('warning') + ->withConsecutive( + [$this->stringContains('Job runner called, however accounting mode is not')] + ); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testValidateRunConditionsFailsIfAnotherJobRunnerIsActive(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(321); + $this->stateStub->method('isStale')->willReturn(false); + $this->cacheMock->method('get')->willReturn($this->stateStub); + + $this->cacheMock->expects($this->never())->method('delete'); + + $this->loggerMock->expects($this->once())->method('debug') + ->with($this->stringContains('Another job runner is active.')); + $this->loggerMock->expects($this->once())->method('info') + ->with($this->stringContains('Run conditions are not met, stopping.')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testAssumeTrueOnJobRunnerActivityIfThrown(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(321); + $this->stateStub->method('isStale')->willReturn(false); + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + $this->stateStub, + $this->throwException(new Exception('test')) + ); + + $this->cacheMock->expects($this->never())->method('delete'); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('Error checking if another job runner is active.')); + $this->loggerMock->expects($this->once())->method('info') + ->with($this->stringContains('Run conditions are not met, stopping.')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testCanLogCacheClearingError(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(321); + $this->stateStub->method('isStale')->willReturn(false); + $this->cacheMock->method('get')->willThrowException(new Exception('test')); + $this->cacheMock->method('delete')->willThrowException(new Exception('test')); + + $this->expectException(Exception::class); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('Error clearing job runner cache.')); + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testValidateRunConditionsSuccessIfStaleStateEncountered(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(321); + $this->stateStub->method('isStale')->willReturn(true); + $this->cacheMock->method('get') + ->willReturnOnConsecutiveCalls( + null, + $this->stateStub + ); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('Assuming no job runner is active.')); + $this->loggerMock->expects($this->once())->method('debug') + ->with($this->stringContains('Run conditions validated.')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testShouldRunCheckFailsIfMaximumExecutionTimeIsReached(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->moduleConfigurationStub->method('getJobRunnerMaximumExecutionTime') + ->willReturn(new \DateInterval('PT1S')); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->cacheMock->method('get')->willReturn(null); + + $this->stateStub->method('getStartedAt')->willReturn(new \DateTimeImmutable('-2 seconds')); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->atLeast(2))->method('debug') + ->withConsecutive( + [$this->stringContains('Run conditions validated.')], + [$this->stringContains('Maximum job runner execution time reached.')] + ); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testCanUseIniSettingForMaximumExecutionTime(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->moduleConfigurationStub->method('getJobRunnerMaximumExecutionTime') + ->willReturn(new \DateInterval('PT20S')); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + $this->environmentHelperStub->method('isCli')->willReturn(false); + $this->helpersManagerStub->method('getEnvironmentHelper')->willReturn($this->environmentHelperStub); + $this->dateTimeHelperStub->method('convertDateIntervalToSeconds')->willReturn(20); + $this->helpersManagerStub->method('getDateTimeHelper')->willReturn($this->dateTimeHelperStub); + + ini_set('max_execution_time', '10'); + + $this->cacheMock->method('get')->willReturn(null); + + $this->stateStub->method('getStartedAt')->willReturn(new \DateTimeImmutable('-30 seconds')); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->atLeast(3))->method('debug') + ->withConsecutive( + [$this->stringContains('Using maximum execution time from INI setting since it is shorter.')], + [$this->stringContains('Run conditions validated.')], + [$this->stringContains('Maximum job runner execution time reached.')] + ); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testShouldRunCheckFailsIfMaximumNumberOfProcessedJobsIsReached(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->cacheMock->method('get')->willReturn(null); + + $this->stateStub->method('getTotalJobsProcessed')->willReturn(PHP_INT_MAX); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->atLeast(2))->method('debug') + ->withConsecutive( + [$this->stringContains('Run conditions validated.')], + [$this->stringContains('Maximum number of processed jobs reached.')] + ); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testValidateSelfStateFailsIfRunHasNotStartedButCachedStateExists(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(false); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('cached state has already been initialized.')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testValidateSelfStateFailsIfRunHasStartedButCachedStateDoesNotExist(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + null, + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('cached state has not been initialized.')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testValidateSelfStateFailsIfRunHasStartedButDifferentJobRunnerIdEncountered(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(321); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('Current job runner ID differs from the ID in the cached state.')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testValidateSelfStateFailsIfRunHasStartedButStaleCachedStateEncountered(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(true); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('Job runner cached state is stale')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testValidateSelfStateFailsIfRunHasStartedButGracefulInterruptIsInitiated(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(false); + $this->stateStub->method('getIsGracefulInterruptInitiated')->willReturn(true); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('Graceful job processing interrupt initiated')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testCanDoBackoffPauseIfNoJobsInCli(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + $this->environmentHelperStub->method('isCli')->willReturn(true); + $this->helpersManagerStub->method('getEnvironmentHelper')->willReturn($this->environmentHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(false); + $this->stateStub->method('getIsGracefulInterruptInitiated') + ->willReturnOnConsecutiveCalls(false, true); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->atLeast(2))->method('debug') + ->withConsecutive( + [$this->stringContains('Run conditions validated.')], + [$this->stringContains('Doing a backoff pause')] + ); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('Graceful job processing interrupt initiated')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testCanBreakImmediatelyIfNoJobsInWeb(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + $this->environmentHelperStub->method('isCli')->willReturn(false); + $this->helpersManagerStub->method('getEnvironmentHelper')->willReturn($this->environmentHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(false); + $this->stateStub->method('getIsGracefulInterruptInitiated') + ->willReturnOnConsecutiveCalls(false, true); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('debug') + ->with($this->stringContains('Run conditions validated')); + + $this->loggerMock->expects($this->never())->method('warning') + ->with($this->stringContains('Graceful job processing interrupt initiated')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testCanProcessJob(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + $this->moduleConfigurationStub->method('getDefaultDataTrackerAndProviderClass') + ->willReturn('mock'); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + $this->environmentHelperStub->method('isCli')->willReturn(false); + $this->helpersManagerStub->method('getEnvironmentHelper')->willReturn($this->environmentHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(false); + $this->stateStub->method('getIsGracefulInterruptInitiated') + ->willReturnOnConsecutiveCalls(false, true); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->rateLimiterMock->expects($this->once())->method('resetBackoffPause'); + + $this->authenticationDataTrackerMock->expects($this->once()) + ->method('process'); + $this->authenticationDataTrackerBuilderStub->method('build') + ->willReturn($this->authenticationDataTrackerMock); + + $this->jobStub->method('getPayload')->willReturn($this->payloadStub); + $this->jobsStoreMock->method('dequeue')->willReturn($this->jobStub); + $this->jobsStoreBuilderStub->method('build')->willReturn($this->jobsStoreMock); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->atLeast(2))->method('debug') + ->withConsecutive( + [$this->stringContains('Run conditions validated.')], + [$this->stringContains('Successfully processed job with ID')] + ); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('Graceful job processing interrupt initiated')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testCanLogCacheUpdateError(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + $this->moduleConfigurationStub->method('getDefaultDataTrackerAndProviderClass') + ->willReturn('mock'); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + $this->environmentHelperStub->method('isCli')->willReturn(false); + $this->helpersManagerStub->method('getEnvironmentHelper')->willReturn($this->environmentHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(false); + $this->stateStub->method('getIsGracefulInterruptInitiated') + ->willReturnOnConsecutiveCalls(false, true); + + $this->cacheMock->method('set')->willThrowException(new Exception('test')); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->jobStub->method('getPayload')->willReturn($this->payloadStub); + $this->jobsStoreMock->method('dequeue')->willReturn($this->jobStub); + $this->jobsStoreBuilderStub->method('build')->willReturn($this->jobsStoreMock); + + $this->loggerMock->expects($this->atLeast(1))->method('debug') + ->withConsecutive( + [$this->stringContains('Run conditions validated.')], + ); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('Error setting job runner state')); + + $this->expectException(Exception::class); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testCanPauseProcessingBasedOnConfiguration(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + $this->moduleConfigurationStub->method('getDefaultDataTrackerAndProviderClass') + ->willReturn('mock'); + $this->moduleConfigurationStub->method('getJobRunnerShouldPauseAfterNumberOfJobsProcessed') + ->willReturn(0); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + $this->environmentHelperStub->method('isCli')->willReturn(false); + $this->helpersManagerStub->method('getEnvironmentHelper')->willReturn($this->environmentHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(false); + $this->stateStub->method('getIsGracefulInterruptInitiated') + ->willReturnOnConsecutiveCalls(false, false, true); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub, + $this->stateStub, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->rateLimiterMock->expects($this->exactly(2))->method('resetBackoffPause'); + $this->rateLimiterMock->expects($this->once())->method('doPause'); + + $this->authenticationDataTrackerMock->expects($this->exactly(2)) + ->method('process'); + $this->authenticationDataTrackerBuilderStub->method('build') + ->willReturn($this->authenticationDataTrackerMock); + + $this->jobStub->method('getPayload')->willReturn($this->payloadStub); + $this->jobsStoreMock->method('dequeue')->willReturn($this->jobStub); + $this->jobsStoreBuilderStub->method('build')->willReturn($this->jobsStoreMock); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->atLeast(3))->method('debug') + ->withConsecutive( + [$this->stringContains('Run conditions validated.')], + [$this->stringContains('Successfully processed job with ID')], + [$this->stringContains('Successfully processed job with ID')] + ); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('Graceful job processing interrupt initiated')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testCanMarkFailedJobOnError(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + $this->moduleConfigurationStub->method('getDefaultDataTrackerAndProviderClass') + ->willReturn('mock'); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + $this->environmentHelperStub->method('isCli')->willReturn(false); + $this->helpersManagerStub->method('getEnvironmentHelper')->willReturn($this->environmentHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(false); + $this->stateStub->method('getIsGracefulInterruptInitiated') + ->willReturnOnConsecutiveCalls(false, true); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + null, + $this->stateStub, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->rateLimiterMock->expects($this->once())->method('resetBackoffPause'); + + $this->authenticationDataTrackerMock->expects($this->once()) + ->method('process') + ->willThrowException(new Exception('test')); + $this->authenticationDataTrackerBuilderStub->method('build') + ->willReturn($this->authenticationDataTrackerMock); + + $this->jobStub->method('getPayload')->willReturn($this->payloadStub); + $this->jobsStoreMock->method('dequeue')->willReturn($this->jobStub); + $this->jobsStoreMock->expects($this->once())->method('markFailedJob')->with($this->jobStub); + $this->jobsStoreBuilderStub->method('build')->willReturn($this->jobsStoreMock); + + $this->cacheMock->expects($this->once())->method('delete'); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('Error while processing jobs.')); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('Graceful job processing interrupt initiated')); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } + + public function testThrowsOnAlreadyInitializedState(): void + { + $this->moduleConfigurationStub->method('getAccountingProcessingType') + ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS); + $this->moduleConfigurationStub->method('getDefaultDataTrackerAndProviderClass') + ->willReturn('mock'); + + $this->randomHelperStub->method('getRandomInt')->willReturn(123); + $this->helpersManagerStub->method('getRandomHelper')->willReturn($this->randomHelperStub); + $this->environmentHelperStub->method('isCli')->willReturn(false); + $this->helpersManagerStub->method('getEnvironmentHelper')->willReturn($this->environmentHelperStub); + + $this->stateStub->method('getJobRunnerId')->willReturn(123); + $this->stateStub->method('isStale')->willReturn(false); + $this->stateStub->method('getIsGracefulInterruptInitiated') + ->willReturnOnConsecutiveCalls(false, true); + + $this->cacheMock->method('get')->willReturnOnConsecutiveCalls( + null, + null, + $this->stateStub + ); + + $this->stateStub->method('hasRunStarted')->willReturn(true); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('Job runner state already initialized')); + $this->expectException(Exception::class); + + $jobRunner = new JobRunner( + $this->moduleConfigurationStub, + $this->sspConfigurationStub, + $this->loggerMock, + $this->helpersManagerStub, + $this->authenticationDataTrackerBuilderStub, + $this->jobsStoreBuilderStub, + $this->cacheMock, + $this->stateStub, + $this->rateLimiterMock + ); + + $jobRunner->run(); + } +} diff --git a/tests/src/Services/LoggerServiceTest.php b/tests/src/Services/LoggerServiceTest.php new file mode 100644 index 0000000000000000000000000000000000000000..241dfeb4a106a5d05bb64ea84d048dc431dcd7e5 --- /dev/null +++ b/tests/src/Services/LoggerServiceTest.php @@ -0,0 +1,31 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Services; + +use SimpleSAML\Module\accounting\Services\Logger; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Services\Logger + */ +class LoggerServiceTest extends TestCase +{ + public function testCanCallAllMethods(): void + { + $loggerService = new Logger(); + + $loggerService->stats('test'); + $loggerService->debug('test'); + $loggerService->info('test'); + $loggerService->notice('test'); + $loggerService->warning('test'); + $loggerService->error('test'); + $loggerService->alert('test'); + $loggerService->critical('test'); + $loggerService->emergency('test'); + + $loggerService->emergency('test', ['sample' => 'context']); + + $this->assertTrue(true); // Nothing to evaluate + } +} diff --git a/tests/src/Stores/Bases/AbstractStoreTest.php b/tests/src/Stores/Bases/AbstractStoreTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5580ba7d68e59ff158034f425aac2a101f991d5f --- /dev/null +++ b/tests/src/Stores/Bases/AbstractStoreTest.php @@ -0,0 +1,63 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Bases; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Stores\Bases\AbstractStore; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + */ +class AbstractStoreTest extends TestCase +{ + /** + * @var AbstractStore + */ + protected $abstractStore; + /** + * @var \PHPUnit\Framework\MockObject\Stub|ModuleConfiguration + */ + protected $moduleConfigurationStub; + /** + * @var \PHPUnit\Framework\MockObject\Stub|LoggerInterface + */ + protected $loggerStub; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->loggerStub = $this->createStub(LoggerInterface::class); + + $this->abstractStore = new class ( + $this->moduleConfigurationStub, + $this->loggerStub + ) extends AbstractStore { + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null + ): AbstractStore { + return new self($moduleConfiguration, $logger, $connectionKey); + } + public function needsSetup(): bool + { + return false; + } + public function runSetup(): void + { + } + }; + } + + public function testCanBuildInstance(): void + { + $this->assertInstanceOf(AbstractStore::class, $this->abstractStore); + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertInstanceOf( + AbstractStore::class, + $this->abstractStore::build($this->moduleConfigurationStub, $this->loggerStub) + ); + } +} diff --git a/tests/src/Stores/Bases/DoctrineDbal/AbstractRawEntityTest.php b/tests/src/Stores/Bases/DoctrineDbal/AbstractRawEntityTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9f8ae4cf8bc0acc63e322a7c73a7875d52932652 --- /dev/null +++ b/tests/src/Stores/Bases/DoctrineDbal/AbstractRawEntityTest.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Bases\DoctrineDbal; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Test\Module\accounting\Constants\DateTime; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity + */ +class AbstractRawEntityTest extends TestCase +{ + /** + * @var AbstractPlatform|AbstractPlatform&\PHPUnit\Framework\MockObject\Stub|\PHPUnit\Framework\MockObject\Stub + */ + protected $abstractPlatformStub; + /** + * @var string[] + */ + protected array $rawRow; + + protected function setUp(): void + { + $this->abstractPlatformStub = $this->createStub(AbstractPlatform::class); + $this->abstractPlatformStub->method('getDateTimeFormatString') + ->willReturn(DateTime::DEFAULT_FORMAT); + $this->rawRow = ['sample' => 'test']; + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress InvalidArgument */ + $rawEntityInstance = new class ($this->rawRow, $this->abstractPlatformStub) extends AbstractRawEntity { + protected function validate( + array $rawRow + ): void { + } + }; + $this->assertInstanceOf(AbstractRawEntity::class, $rawEntityInstance); + } + + public function testCanResolveDateTimeImmutable(): void + { + /** @psalm-suppress InvalidArgument */ + $rawEntityInstance = new class ($this->rawRow, $this->abstractPlatformStub) extends AbstractRawEntity { + protected function validate( + array $rawRow + ): void { + $this->resolveDateTimeImmutable('2022-09-21 14:49:20'); + } + }; + + $this->assertInstanceOf(AbstractRawEntity::class, $rawEntityInstance); + } + + public function testThrowsForInvalidDateTime(): void + { + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress InvalidArgument */ + new class ($this->rawRow, $this->abstractPlatformStub) extends AbstractRawEntity { + protected function validate( + array $rawRow + ): void { + $this->resolveDateTimeImmutable('invalid'); + } + }; + } +} diff --git a/tests/src/Stores/Builders/DataStoreBuilderTest.php b/tests/src/Stores/Builders/DataStoreBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c007b8897c6c638c953ba2c0aeeda2beb875e0df --- /dev/null +++ b/tests/src/Stores/Builders/DataStoreBuilderTest.php @@ -0,0 +1,63 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Builders; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store + * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore + * @uses \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * @uses \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + */ +class DataStoreBuilderTest extends TestCase +{ + protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub; + protected \PHPUnit\Framework\MockObject\Stub $loggerStub; + protected DataStoreBuilder $dataStoreBuilder; + protected HelpersManager $helpersManager; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY); + + $this->loggerStub = $this->createStub(LoggerInterface::class); + + $this->helpersManager = new HelpersManager(); + + /** @psalm-suppress InvalidArgument */ + $this->dataStoreBuilder = new DataStoreBuilder( + $this->moduleConfigurationStub, + $this->loggerStub, + $this->helpersManager + ); + } + + public function testCanBuildDataStore(): void + { + $this->assertInstanceOf(Store::class, $this->dataStoreBuilder->build(Store::class)); + } + + public function testThrowsForInvalidDataStoreClass(): void + { + $this->expectException(StoreException::class); + $this->dataStoreBuilder->build(ModuleConfiguration::class); + } +} diff --git a/tests/src/Stores/Builders/JobsStoreBuilderTest.php b/tests/src/Stores/Builders/JobsStoreBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..02166c883e30d3d6168705053a1c85ecce05d78d --- /dev/null +++ b/tests/src/Stores/Builders/JobsStoreBuilderTest.php @@ -0,0 +1,138 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Builders; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder; +use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder; +use SimpleSAML\Module\accounting\Stores\Interfaces\StoreInterface; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder + * @covers \SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore + * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * @uses \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + */ +class JobsStoreBuilderTest extends TestCase +{ + protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub; + protected \PHPUnit\Framework\MockObject\Stub $loggerStub; + protected JobsStoreBuilder $jobsStoreBuilder; + protected HelpersManager $helpersManager; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->moduleConfigurationStub->method('getJobsStoreClass')->willReturn(Store::class); + + $this->loggerStub = $this->createStub(LoggerInterface::class); + + $this->helpersManager = new HelpersManager(); + + /** @psalm-suppress InvalidArgument */ + $this->jobsStoreBuilder = new JobsStoreBuilder( + $this->moduleConfigurationStub, + $this->loggerStub, + $this->helpersManager + ); + } + + public function testCanBuildJobsStore(): void + { + $this->assertInstanceOf(Store::class, $this->jobsStoreBuilder->build(Store::class)); + } + + public function testThrowsForInvalidStoreClass(): void + { + $moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY); + + $invalidStore = new class { + }; + + /** @psalm-suppress InvalidArgument */ + $storeBuilder = new class ( + $moduleConfigurationStub, + $this->loggerStub, + $this->helpersManager + ) extends AbstractStoreBuilder { + public function build( + string $class, + string $connectionKey = null, + string $connectionType = ModuleConfiguration\ConnectionType::MASTER + ): StoreInterface { + return $this->buildGeneric($class, [$connectionKey, $connectionType]); + } + }; + + $this->expectException(StoreException::class); + + /** @psalm-suppress InvalidArgument */ + $storeBuilder->build(get_class($invalidStore)); + } + + public function testThrowsForInvalidJobsStoreClass(): void + { + $moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY); + + $this->expectException(StoreException::class); + + /** @psalm-suppress InvalidArgument */ + (new JobsStoreBuilder($moduleConfigurationStub, $this->loggerStub, $this->helpersManager)) + ->build('invalid'); + } + + public function testJobsStoreBuilderOnlyReturnsJobsStores(): void + { + $sampleStore = new class implements StoreInterface { + public function needsSetup(): bool + { + return false; + } + + public function runSetup(): void + { + } + + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger, + string $connectionKey = null + ): StoreInterface { + return new self(); + } + }; + + $moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY); + $moduleConfigurationStub->method('getJobsStoreClass')->willReturn(get_class($sampleStore)); + + $this->expectException(StoreException::class); + + /** @psalm-suppress InvalidArgument */ + (new JobsStoreBuilder($moduleConfigurationStub, $this->loggerStub, $this->helpersManager)) + ->build(get_class($sampleStore)); + } +} diff --git a/tests/src/Stores/Connections/Bases/AbstractMigratorTest.php b/tests/src/Stores/Connections/Bases/AbstractMigratorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0fb3fda9f697c817da1f36fe9d763f1fc89f091e --- /dev/null +++ b/tests/src/Stores/Connections/Bases/AbstractMigratorTest.php @@ -0,0 +1,185 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Connections\Bases; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\Logger; +use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper + * @uses \SimpleSAML\Module\accounting\ModuleConfiguration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class AbstractMigratorTest extends TestCase +{ + protected Connection $connection; + protected AbstractSchemaManager $schemaManager; + protected string $tableName; + + /** + * @var \PHPUnit\Framework\MockObject\MockObject + */ + protected $loggerServiceMock; + protected ModuleConfiguration $moduleConfiguration; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = $this->connection->preparePrefixedTableName(Migrator::TABLE_NAME); + + $this->loggerServiceMock = $this->createMock(Logger::class); + + // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml). + $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php'); + } + + public function testCanGatherMigrationClassesFromDirectory(): void + { + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $directory = $this->getSampleMigrationsDirectory(); + + $namespace = $this->getSampleNameSpace(); + + $migrationClasses = $migrator->gatherMigrationClassesFromDirectory($directory, $namespace); + + $this->assertTrue(in_array($namespace . '\Version20220601000000CreateJobTable', $migrationClasses)); + } + + public function testCanRunMigrationClasses(): void + { + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $migrator->runSetup(); + + $directory = $this->getSampleMigrationsDirectory(); + + $namespace = $this->getSampleNameSpace(); + + $migrationClasses = $migrator->gatherMigrationClassesFromDirectory($directory, $namespace); + + $jobsTableName = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB); + + $this->assertFalse($this->schemaManager->tablesExist($jobsTableName)); + + $migrator->runMigrationClasses($migrationClasses); + + $this->assertTrue($this->schemaManager->tablesExist($jobsTableName)); + } + + public function testCanGatherOnlyMigrationClasses(): void + { + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $directory = __DIR__; + $namespace = __NAMESPACE__; + + $this->assertEmpty($migrator->gatherMigrationClassesFromDirectory($directory, $namespace)); + } + + public function testMigrationExceptionHaltsExecution(): void + { + $migration = new class ($this->connection) extends AbstractMigration + { + public function run(): void + { + throw new \Exception('Something went wrong.'); + } + + public function revert(): void + { + } + }; + + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $this->expectException(MigrationException::class); + + $migrator->runMigrationClasses([get_class($migration)]); + } + + public function testCanGetNonImplementedMigrationClasses(): void + { + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $migrator->runSetup(); + + $nonImplementedMigrationClasses = $migrator->getNonImplementedMigrationClasses( + $this->getSampleMigrationsDirectory(), + $this->getSampleNameSpace() + ); + + $this->assertTrue(in_array( + Store\Migrations\Version20220601000000CreateJobTable::class, + $nonImplementedMigrationClasses + )); + } + + public function testCanFindOutIfNonImplementedMigrationClassesExist(): void + { + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $migrator->runSetup(); + + $this->assertTrue($migrator->hasNonImplementedMigrationClasses( + $this->getSampleMigrationsDirectory(), + $this->getSampleNameSpace() + )); + } + + public function testCanRunNonImplementedMigrationClasses(): void + { + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $migrator->runSetup(); + + $directory = $this->getSampleMigrationsDirectory(); + $namespace = $this->getSampleNameSpace(); + + $this->assertTrue($migrator->hasNonImplementedMigrationClasses($directory, $namespace)); + + $migrator->runNonImplementedMigrationClasses($directory, $namespace); + + $this->assertFalse($migrator->hasNonImplementedMigrationClasses($directory, $namespace)); + } + + protected function getSampleMigrationsDirectory(): string + { + return $this->moduleConfiguration->getModuleSourceDirectory() . DIRECTORY_SEPARATOR . + 'Stores' . DIRECTORY_SEPARATOR . 'Jobs' . DIRECTORY_SEPARATOR . 'DoctrineDbal' . DIRECTORY_SEPARATOR . + 'Store' . DIRECTORY_SEPARATOR . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME; + } + + protected function getSampleNameSpace(): string + { + return Store::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME; + } +} diff --git a/tests/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigrationTest.php b/tests/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigrationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..68db88bdeed0a9453949a55c4ae639363b2e008f --- /dev/null +++ b/tests/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigrationTest.php @@ -0,0 +1,115 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Connections\DoctrineDbal\Bases; + +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + */ +class AbstractMigrationTest extends TestCase +{ + protected Connection $connection; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + } + + public function testCanInstantiateMigrationClass(): void + { + $this->assertInstanceOf( + AbstractMigration::class, + new Version20220601000000CreateJobTable($this->connection) + ); + } + + public function testThrowsStoreException(): void + { + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $dbalStub->method('createSchemaManager') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $connectionStub = $this->createStub(Connection::class); + $connectionStub->method('dbal')->willReturn($dbalStub); + + $this->expectException(StoreException::class); + + (new Version20220601000000CreateJobTable($connectionStub)); + } + + public function testCanThrowGenericMigrationExceptionOnRun(): void + { + $migration = new class ($this->connection) extends AbstractMigration { + public function run(): void + { + throw $this->prepareGenericMigrationException('test', new \Exception('test')); + } + + public function revert(): void + { + } + }; + + $this->expectException(StoreException\MigrationException::class); + + $migration->run(); + } + + public function testCanUseTableNamePrefix(): void + { + $connectionStub = $this->createStub(Connection::class); + $connectionStub->method('dbal')->willReturn($this->connection->dbal()); + $connectionStub->method('preparePrefixedTableName')->willReturn('prefix-connection'); + + $migration = new class ($connectionStub) extends AbstractMigration { + public function run(): void + { + throw new \Exception($this->preparePrefixedTableName('table-name')); + } + public function revert(): void + { + } + protected function getLocalTablePrefix(): string + { + return 'prefix-local'; + } + }; + + try { + $migration->run(); + } catch (\Exception $exception) { + $this->assertStringContainsString('prefix-connection', $exception->getMessage()); + } + } + + public function testCanUseLocalTableNamePrefix(): void + { + $connectionStub = $this->createStub(Connection::class); + $connectionStub->method('dbal')->willReturn($this->connection->dbal()); + $connectionStub->method('preparePrefixedTableName')->willReturn('prefix-connection'); + + $migration = new class ($connectionStub) extends AbstractMigration { + public function run(): void + { + throw new \Exception($this->getLocalTablePrefix()); + } + public function revert(): void + { + } + }; + + try { + $migration->run(); + } catch (\Exception $exception) { + $this->assertEmpty($exception->getMessage()); + } + } +} diff --git a/tests/src/Stores/Connections/DoctrineDbal/ConnectionTest.php b/tests/src/Stores/Connections/DoctrineDbal/ConnectionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..cbc1f869dfb10fdabf9299c0d265eec401388b63 --- /dev/null +++ b/tests/src/Stores/Connections/DoctrineDbal/ConnectionTest.php @@ -0,0 +1,55 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Connections\DoctrineDbal; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + */ +class ConnectionTest extends TestCase +{ + protected array $parameters = [ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ]; + + public function testCanInstantiateDbalConnection(): void + { + $connection = new Connection($this->parameters); + + $this->assertInstanceOf(\Doctrine\DBAL\Connection::class, $connection->dbal()); + } + + public function testInvalidConnectionParametersThrow(): void + { + $this->expectException(InvalidConfigurationException::class); + (new Connection(['invalid' => 'parameter'])); + } + + public function testCanSetTablePrefix(): void + { + $prefix = 'test_'; + $parameters = $this->parameters; + $parameters['table_prefix'] = $prefix; + + $connection = new Connection($parameters); + + $this->assertEquals($prefix, $connection->getTablePrefix()); + + $this->assertSame('test_test', $connection->preparePrefixedTableName('test')); + } + + public function testTablePrefixParameterThrowsIfNotString(): void + { + $this->expectException(InvalidConfigurationException::class); + + $parameters = $this->parameters; + $parameters['table_prefix'] = new class () { + }; + + (new Connection($parameters)); + } +} diff --git a/tests/src/Stores/Connections/DoctrineDbal/FactoryTest.php b/tests/src/Stores/Connections/DoctrineDbal/FactoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..07dab2dbfcd72e2a5b750e9edec9b0a581d9c290 --- /dev/null +++ b/tests/src/Stores/Connections/DoctrineDbal/FactoryTest.php @@ -0,0 +1,54 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Connections\DoctrineDbal; + +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\Logger; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory + * @uses \SimpleSAML\Module\accounting\ModuleConfiguration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class FactoryTest extends TestCase +{ + protected ModuleConfiguration $moduleConfiguration; + + /** + * @var \PHPUnit\Framework\MockObject\MockObject + */ + protected $loggerServiceMock; + + protected function setUp(): void + { + // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml). + $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php'); + $this->loggerServiceMock = $this->createMock(Logger::class); + } + + public function testCanBuildConnection(): void + { + /** @psalm-suppress InvalidArgument */ + $factory = new Factory($this->moduleConfiguration, $this->loggerServiceMock); + + $this->assertInstanceOf(Connection::class, $factory->buildConnection('doctrine_dbal_pdo_sqlite')); + } + + public function testCanBuildMigrator(): void + { + /** @psalm-suppress InvalidArgument */ + $factory = new Factory($this->moduleConfiguration, $this->loggerServiceMock); + + $connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + + $this->assertInstanceOf(Migrator::class, $factory->buildMigrator($connection)); + } +} diff --git a/tests/src/Stores/Connections/DoctrineDbal/MigratorTest.php b/tests/src/Stores/Connections/DoctrineDbal/MigratorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..907e83c6b66d26e1865453145d63b11e1dc02729 --- /dev/null +++ b/tests/src/Stores/Connections/DoctrineDbal/MigratorTest.php @@ -0,0 +1,238 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Connections\DoctrineDbal; + +use Doctrine\DBAL\Query\QueryBuilder; +use PHPUnit\Framework\TestCase; +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\InvalidValueException; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\Logger; +use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Interfaces\MigrationInterface; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +use function PHPUnit\Framework\assertFalse; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @covers \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable + * @uses \SimpleSAML\Module\accounting\ModuleConfiguration + * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class MigratorTest extends TestCase +{ + protected Connection $connection; + protected AbstractSchemaManager $schemaManager; + protected string $tableName; + + /** + * @var \PHPUnit\Framework\MockObject\MockObject + */ + protected $loggerServiceMock; + protected ModuleConfiguration $moduleConfiguration; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = $this->connection->preparePrefixedTableName(Migrator::TABLE_NAME); + + $this->loggerServiceMock = $this->createMock(Logger::class); + + // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml). + $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php'); + } + + public function testCanCreateMigrationsTable(): void + { + $this->assertFalse($this->schemaManager->tablesExist([$this->tableName])); + + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $this->assertTrue($migrator->needsSetup()); + + $migrator->runSetup(); + + $this->assertFalse($migrator->needsSetup()); + $this->assertTrue($this->schemaManager->tablesExist([$this->tableName])); + } + + public function testRunningMigratorSetupMultipleTimesLogsWarning(): void + { + $this->loggerServiceMock + ->expects($this->once()) + ->method('warning') + ->with($this->stringContains('setup is not needed')); + + /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $this->assertTrue($migrator->needsSetup()); + + $migrator->runSetup(); + $migrator->runSetup(); + } + + public function testCanRunMigrationClasses(): void + { + /** @psalm-suppress InvalidArgument */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $migrator->runSetup(); + + $tableNameJobs = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB); + $this > assertFalse($this->schemaManager->tablesExist($tableNameJobs)); + + $migrator->runMigrationClasses([Store\Migrations\Version20220601000000CreateJobTable::class]); + + $this->assertTrue($this->schemaManager->tablesExist($tableNameJobs)); + } + + public function testCanOnlyRunDoctrineDbalMigrationClasses(): void + { + $migration = new class implements MigrationInterface { + public function run(): void + { + } + public function revert(): void + { + } + }; + + /** @psalm-suppress InvalidArgument */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $migrator->runSetup(); + + $this->expectException(InvalidValueException::class); + + $migrator->runMigrationClasses([get_class($migration)]); + } + + public function testCanGetImplementedMigrationClasses(): void + { + /** @psalm-suppress InvalidArgument */ + $migrator = new Migrator($this->connection, $this->loggerServiceMock); + + $migrator->runSetup(); + + $this->assertEmpty($migrator->getImplementedMigrationClasses()); + + $migrator->runNonImplementedMigrationClasses( + $this->getSampleMigrationsDirectory(), + $this->getSampleNameSpace() + ); + + $this->assertNotEmpty($migrator->getImplementedMigrationClasses()); + } + + public function testThrowsStoreExceptionOnInitialization(): void + { + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $dbalStub->method('createSchemaManager')->willThrowException(new \Doctrine\DBAL\Exception('test')); + $connectionStub = $this->createStub(Connection::class); + $connectionStub->method('dbal')->willReturn($dbalStub); + + $this->expectException(StoreException::class); + + /** @psalm-suppress InvalidArgument */ + (new Migrator($connectionStub, $this->loggerServiceMock)); + } + + public function testThrowsStoreExceptionOnNeedsSetup(): void + { + $schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + $schemaManagerStub->method('tablesExist') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $dbalStub->method('createSchemaManager')->willReturn($schemaManagerStub); + $connectionStub = $this->createStub(Connection::class); + $connectionStub->method('dbal')->willReturn($dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migrator = new Migrator($connectionStub, $this->loggerServiceMock); + + $this->expectException(StoreException::class); + + $migrator->needsSetup(); + } + + public function testThrowsStoreExceptionOnCreateMigrationsTable(): void + { + $schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + $schemaManagerStub->method('tablesExist') + ->willReturn(false); + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $dbalStub->method('createSchemaManager')->willReturn($schemaManagerStub); + $connectionStub = $this->createStub(Connection::class); + $connectionStub->method('dbal')->willReturn($dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migrator = new Migrator($connectionStub, $this->loggerServiceMock); + + $this->expectException(StoreException::class); + + $migrator->runSetup(); + } + + public function testThrowsStoreExceptionOnMarkingImplementedClass(): void + { + $queryBuilderStub = $this->createStub(QueryBuilder::class); + $queryBuilderStub->method('insert') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $dbalStub->method('createQueryBuilder')->willReturn($queryBuilderStub); + $connectionStub = $this->createStub(Connection::class); + $connectionStub->method('dbal')->willReturn($dbalStub); + $connectionStub->method('preparePrefixedTableName')->willReturn(Migrator::TABLE_NAME); + + /** @psalm-suppress InvalidArgument */ + $migrator = new Migrator($connectionStub, $this->loggerServiceMock); + $migrator->runSetup(); + + $this->expectException(StoreException::class); + + $migrator->runMigrationClasses([Store\Migrations\Version20220601000000CreateJobTable::class]); + } + + public function testThrowsStoreExceptionOnGetImplementedMigrationClasses(): void + { + $schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $dbalStub->method('createQueryBuilder')->willThrowException(new \Doctrine\DBAL\Exception('test')); + $dbalStub->method('createSchemaManager')->willReturn($schemaManagerStub); + $connectionStub = $this->createStub(Connection::class); + $connectionStub->method('dbal')->willReturn($dbalStub); + + $this->expectException(StoreException::class); + + /** @psalm-suppress InvalidArgument */ + (new Migrator($connectionStub, $this->loggerServiceMock))->getImplementedMigrationClasses(); + } + + protected function getSampleMigrationsDirectory(): string + { + return $this->moduleConfiguration->getModuleSourceDirectory() . DIRECTORY_SEPARATOR . + 'Stores' . DIRECTORY_SEPARATOR . 'Jobs' . DIRECTORY_SEPARATOR . 'DoctrineDbal' . DIRECTORY_SEPARATOR . + 'Store' . DIRECTORY_SEPARATOR . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME; + } + + protected function getSampleNameSpace(): string + { + return Store::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME; + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedStateTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedStateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..23cb877c07e19c1100077af13a76191ae585fdd0 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedStateTest.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\HashDecoratedState; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\HashDecoratedState + * @uses \SimpleSAML\Module\accounting\Helpers\HashHelper + * @uses \SimpleSAML\Module\accounting\Helpers\ArrayHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class HashDecoratedStateTest extends TestCase +{ + /** + * @var \PHPUnit\Framework\MockObject\Stub|State|State&\PHPUnit\Framework\MockObject\Stub + */ + protected $stateStub; + protected string $identityProviderEntityId; + /** + * @var string[] + */ + protected array $identityProviderMetadata; + protected string $serviceProviderEntityId; + /** + * @var string[] + */ + protected array $serviceProviderMetadata; + /** + * @var string[] + */ + protected array $attributes; + + protected function setUp(): void + { + $this->stateStub = $this->createStub(State::class); + $this->identityProviderEntityId = 'idpEntityId'; + $this->stateStub->method('getIdentityProviderEntityId')->willReturn($this->identityProviderEntityId); + $this->identityProviderMetadata = ['idp' => 'metadata']; + $this->stateStub->method('getIdentityProviderMetadata')->willReturn($this->identityProviderMetadata); + $this->serviceProviderEntityId = 'spEntityId'; + $this->stateStub->method('getServiceProviderEntityId')->willReturn($this->serviceProviderEntityId); + $this->serviceProviderMetadata = ['sp' => 'metadata']; + $this->stateStub->method('getServiceProviderMetadata')->willReturn($this->serviceProviderMetadata); + $this->attributes = ['sample' => 'attribute']; + $this->stateStub->method('getAttributes')->willReturn($this->attributes); + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertInstanceOf(HashDecoratedState::class, new HashDecoratedState($this->stateStub)); + } + + public function testCanGetHashedProperties(): void + { + /** @psalm-suppress PossiblyInvalidArgument */ + $hashDecoratedState = new HashDecoratedState($this->stateStub); + + $this->assertSame($this->stateStub, $hashDecoratedState->getState()); + $this->assertIsString($hashDecoratedState->getIdentityProviderEntityIdHashSha256()); + $this->assertIsString($hashDecoratedState->getServiceProviderEntityIdHashSha256()); + $this->assertIsString($hashDecoratedState->getIdentityProviderMetadataArrayHashSha256()); + $this->assertIsString($hashDecoratedState->getServiceProviderMetadataArrayHashSha256()); + $this->assertIsString($hashDecoratedState->getAttributesArrayHashSha256()); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2b99a6106c635b609e386f119f3f3ee5b733d47f --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTableTest.php @@ -0,0 +1,84 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000000CreateIdpTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + */ +class Version20220801000000CreateIdpTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + protected \PHPUnit\Framework\MockObject\Stub $connectionStub; + protected \PHPUnit\Framework\MockObject\Stub $dbalStub; + protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = 'vds_idp'; + + $this->connectionStub = $this->createStub(Connection::class); + $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Migrations\Version20220801000000CreateIdpTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName); + $this->schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000000CreateIdpTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $this->schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000000CreateIdpTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } + + public function testRunThrowsOnIvalidTableNameIdp(): void + { + $this->connectionStub->method('preparePrefixedTableName') + ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000000CreateIdpTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..525d7ef8226b7848679136175706122fcc92842f --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTableTest.php @@ -0,0 +1,84 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000100CreateIdpVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection * + */ +class Version20220801000100CreateIdpVersionTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + protected \PHPUnit\Framework\MockObject\Stub $connectionStub; + protected \PHPUnit\Framework\MockObject\Stub $dbalStub; + protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = 'vds_idp_version'; + + $this->connectionStub = $this->createStub(Connection::class); + $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Migrations\Version20220801000100CreateIdpVersionTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName); + $this->schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000100CreateIdpVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $this->schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000100CreateIdpVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } + + public function testRunThrowsOnIvalidTableNameIdp(): void + { + $this->connectionStub->method('preparePrefixedTableName') + ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000100CreateIdpVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..11d7874bb33138093b49fb518f595dd9e8350c06 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTableTest.php @@ -0,0 +1,84 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000200CreateSpTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection * + */ +class Version20220801000200CreateSpTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + protected \PHPUnit\Framework\MockObject\Stub $connectionStub; + protected \PHPUnit\Framework\MockObject\Stub $dbalStub; + protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = 'vds_sp'; + + $this->connectionStub = $this->createStub(Connection::class); + $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Migrations\Version20220801000200CreateSpTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName); + $this->schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000200CreateSpTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $this->schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000200CreateSpTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } + + public function testRunThrowsOnIvalidTableNameIdp(): void + { + $this->connectionStub->method('preparePrefixedTableName') + ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000200CreateSpTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a839507c55379830055302d14a59ad639c16b344 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTableTest.php @@ -0,0 +1,84 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000300CreateSpVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection * + */ +class Version20220801000300CreateSpVersionTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + protected \PHPUnit\Framework\MockObject\Stub $connectionStub; + protected \PHPUnit\Framework\MockObject\Stub $dbalStub; + protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = 'vds_sp_version'; + + $this->connectionStub = $this->createStub(Connection::class); + $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Migrations\Version20220801000300CreateSpVersionTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName); + $this->schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000300CreateSpVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $this->schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000300CreateSpVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } + + public function testRunThrowsOnIvalidTableNameIdp(): void + { + $this->connectionStub->method('preparePrefixedTableName') + ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000300CreateSpVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..eca699c62deb3ea3a84fa044b90ba0b58b59cbe0 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTableTest.php @@ -0,0 +1,84 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000400CreateUserTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection * + */ +class Version20220801000400CreateUserTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + protected \PHPUnit\Framework\MockObject\Stub $connectionStub; + protected \PHPUnit\Framework\MockObject\Stub $dbalStub; + protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = 'vds_user'; + + $this->connectionStub = $this->createStub(Connection::class); + $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Migrations\Version20220801000400CreateUserTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName); + $this->schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000400CreateUserTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $this->schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000400CreateUserTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } + + public function testRunThrowsOnIvalidTableNameIdp(): void + { + $this->connectionStub->method('preparePrefixedTableName') + ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000400CreateUserTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..42384894bdc8596fbcc51848d600c09ec02c112a --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTableTest.php @@ -0,0 +1,84 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000500CreateUserVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection * + */ +class Version20220801000500CreateUserVersionTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + protected \PHPUnit\Framework\MockObject\Stub $connectionStub; + protected \PHPUnit\Framework\MockObject\Stub $dbalStub; + protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = 'vds_user_version'; + + $this->connectionStub = $this->createStub(Connection::class); + $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Migrations\Version20220801000500CreateUserVersionTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName); + $this->schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000500CreateUserVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $this->schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000500CreateUserVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } + + public function testRunThrowsOnIvalidTableNameIdp(): void + { + $this->connectionStub->method('preparePrefixedTableName') + ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000500CreateUserVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ce214e658f70dbdeb91a971cc25c95f7769a7ed5 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTableTest.php @@ -0,0 +1,88 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000600CreateIdpSpUserVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection * + */ +class Version20220801000600CreateIdpSpUserVersionTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + protected \PHPUnit\Framework\MockObject\Stub $connectionStub; + protected \PHPUnit\Framework\MockObject\Stub $dbalStub; + protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = 'vds_idp_sp_user_version'; + + $this->connectionStub = $this->createStub(Connection::class); + $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = + new Migrations\Version20220801000600CreateIdpSpUserVersionTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName); + $this->schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = + new Migrations\Version20220801000600CreateIdpSpUserVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $this->schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = + new Migrations\Version20220801000600CreateIdpSpUserVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } + + public function testRunThrowsOnIvalidTableNameIdp(): void + { + $this->connectionStub->method('preparePrefixedTableName') + ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table + + /** @psalm-suppress InvalidArgument */ + $migration = + new Migrations\Version20220801000600CreateIdpSpUserVersionTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..004b1923c03af384e30e1e941861fdddd665f20f --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTableTest.php @@ -0,0 +1,84 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000700CreateAuthenticationEventTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection * + */ +class Version20220801000700CreateAuthenticationEventTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + protected \PHPUnit\Framework\MockObject\Stub $connectionStub; + protected \PHPUnit\Framework\MockObject\Stub $dbalStub; + protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = 'vds_authentication_event'; + + $this->connectionStub = $this->createStub(Connection::class); + $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Migrations\Version20220801000700CreateAuthenticationEventTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName); + $this->schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000700CreateAuthenticationEventTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $this->schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub); + $this->connectionStub->method('dbal')->willReturn($this->dbalStub); + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000700CreateAuthenticationEventTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } + + public function testRunThrowsOnIvalidTableNameIdp(): void + { + $this->connectionStub->method('preparePrefixedTableName') + ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table + + /** @psalm-suppress InvalidArgument */ + $migration = new Migrations\Version20220801000700CreateAuthenticationEventTable($this->connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivityTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivityTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5c9febc08f268e4dd751e41f9cd521d596fbb991 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivityTest.php @@ -0,0 +1,146 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawActivity; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; +use SimpleSAML\Test\Module\accounting\Constants\DateTime; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawActivity + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity + */ +class RawActivityTest extends TestCase +{ + /** + * @var string[] + */ + protected array $serviceProviderMetadata; + /** + * @var string[] + */ + protected array $userAttributes; + protected string $happenedAt; + protected string $clientIpAddress; + + protected array $rawRow; + /** + * @var AbstractPlatform|AbstractPlatform&\PHPUnit\Framework\MockObject\Stub|\PHPUnit\Framework\MockObject\Stub + */ + protected $abstractPlatformStub; + + protected function setUp(): void + { + $this->serviceProviderMetadata = ['sp' => 'metadata']; + $this->userAttributes = ['user' => 'attribute']; + $this->happenedAt = '2022-02-22 22:22:22'; + $this->clientIpAddress = '123.123.123.123'; + + $this->rawRow = [ + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA => serialize($this->serviceProviderMetadata), + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES => serialize($this->userAttributes), + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT => $this->happenedAt, + TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_CLIENT_IP_ADDRESS => $this->clientIpAddress, + ]; + $this->abstractPlatformStub = $this->createStub(AbstractPlatform::class); + $this->abstractPlatformStub->method('getDateTimeFormatString')->willReturn(DateTime::DEFAULT_FORMAT); + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress PossiblyInvalidArgument */ + $rawActivity = new RawActivity($this->rawRow, $this->abstractPlatformStub); + + $this->assertInstanceOf(RawActivity::class, $rawActivity); + } + + public function testCanGetProperties(): void + { + /** @psalm-suppress PossiblyInvalidArgument */ + $rawActivity = new RawActivity($this->rawRow, $this->abstractPlatformStub); + + $this->assertInstanceOf(\DateTimeImmutable::class, $rawActivity->getHappenedAt()); + $this->assertSame($this->serviceProviderMetadata, $rawActivity->getServiceProviderMetadata()); + $this->assertSame($this->userAttributes, $rawActivity->getUserAttributes()); + $this->assertSame($this->clientIpAddress, $rawActivity->getClientIpAddress()); + } + + public function testIpAddressCanBeMissing(): void + { + $rawRow = $this->rawRow; + unset($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_CLIENT_IP_ADDRESS]); + + /** @psalm-suppress PossiblyInvalidArgument */ + $rawActivity = new RawActivity($rawRow, $this->abstractPlatformStub); + $this->assertNull($rawActivity->getClientIpAddress()); + } + + public function testThrowsIfColumnNotPresent(): void + { + $rawRow = $this->rawRow; + unset($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT]); + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawActivity($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsForNonStringServiceProviderMetadata(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA] = 1; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawActivity($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsForNonStringUserAttributes(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES] = 1; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawActivity($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsForNonStringHappenedAt(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT] = 1; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawActivity($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsForInvalidServiceProviderMetadata(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA] = serialize(1); + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawActivity($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsForInvalidUserAttributes(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES] = serialize(1); + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawActivity($rawRow, $this->abstractPlatformStub); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProviderTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProviderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..68fe4ed768439f1b39228591cdb6e8ef1c898d84 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProviderTest.php @@ -0,0 +1,177 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawConnectedServiceProvider; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; +use SimpleSAML\Test\Module\accounting\Constants\DateTime; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawConnectedServiceProvider + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity + */ +class RawConnectedServiceProviderTest extends TestCase +{ + protected int $numberOfAuthentications; + protected string $lastAuthenticationAt; + protected string $firstAuthenticationAt; + /** + * @var string[] + */ + protected array $serviceProviderMetadata; + /** + * @var string[] + */ + protected array $userAttributes; + protected array $rawRow; + /** + * @var AbstractPlatform|AbstractPlatform&\PHPUnit\Framework\MockObject\Stub|\PHPUnit\Framework\MockObject\Stub + */ + protected $abstractPlatformStub; + protected string $dateTimeFormat; + + protected function setUp(): void + { + $this->numberOfAuthentications = 2; + $this->lastAuthenticationAt = '2022-02-22 22:22:22'; + $this->firstAuthenticationAt = '2022-02-02 22:22:22'; + $this->serviceProviderMetadata = ['sp' => 'metadata']; + $this->userAttributes = ['user' => 'attribute']; + $this->rawRow = [ + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS => + $this->numberOfAuthentications, + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT => + $this->lastAuthenticationAt, + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT => + $this->firstAuthenticationAt, + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA => + serialize($this->serviceProviderMetadata), + TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES => + serialize($this->userAttributes), + ]; + $this->dateTimeFormat = DateTime::DEFAULT_FORMAT; + $this->abstractPlatformStub = $this->createStub(AbstractPlatform::class); + $this->abstractPlatformStub->method('getDateTimeFormatString') + ->willReturn($this->dateTimeFormat); + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertInstanceOf( + RawConnectedServiceProvider::class, + new RawConnectedServiceProvider($this->rawRow, $this->abstractPlatformStub) + ); + } + + public function testCanGetProperties(): void + { + /** @psalm-suppress PossiblyInvalidArgument */ + $rawConnectedServiceProvider = new RawConnectedServiceProvider($this->rawRow, $this->abstractPlatformStub); + + $this->assertSame($this->numberOfAuthentications, $rawConnectedServiceProvider->getNumberOfAuthentications()); + $this->assertInstanceOf(\DateTimeImmutable::class, $rawConnectedServiceProvider->getLastAuthenticationAt()); + $this->assertSame( + $this->lastAuthenticationAt, + $rawConnectedServiceProvider->getLastAuthenticationAt()->format($this->dateTimeFormat) + ); + $this->assertInstanceOf(\DateTimeImmutable::class, $rawConnectedServiceProvider->getFirstAuthenticationAt()); + $this->assertSame( + $this->firstAuthenticationAt, + $rawConnectedServiceProvider->getFirstAuthenticationAt()->format($this->dateTimeFormat) + ); + $this->assertSame($this->serviceProviderMetadata, $rawConnectedServiceProvider->getServiceProviderMetadata()); + $this->assertSame($this->userAttributes, $rawConnectedServiceProvider->getUserAttributes()); + } + + public function testThrowsIfColumnNotSet(): void + { + $rawRow = $this->rawRow; + unset($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES]); + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsIfNumberOfAuthenticationsNotNumeric(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS] = 'a'; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsIfLastAuthenticationAtNotString(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT] = 1; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsIfFirstAuthenticationAtNotString(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT] = 1; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsIfSpMetadataNotString(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] = 1; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsIfUserAttributesNotString(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] = 1; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsIfSpMetadataNotValid(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] = serialize(1); + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub); + } + + public function testThrowsIfUserAttributesNotValid(): void + { + $rawRow = $this->rawRow; + $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] = serialize(1); + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RepositoryTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RepositoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..63144f4399de5686a7c2eb91673614d5cb7e6018 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RepositoryTest.php @@ -0,0 +1,789 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; +use SimpleSAML\Test\Module\accounting\Constants\DateTime; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository + * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper + * @uses \SimpleSAML\Module\accounting\ModuleConfiguration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000000CreateIdpTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000100CreateIdpVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000200CreateSpTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000300CreateSpVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000400CreateUserTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000500CreateUserVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000600CreateIdpSpUserVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000700CreateAuthenticationEventTable + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * + * @psalm-suppress all + */ +class RepositoryTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Connection $dbal; + /** + * @var \PHPUnit\Framework\MockObject\Stub|LoggerInterface|LoggerInterface&\PHPUnit\Framework\MockObject\Stub + */ + protected $loggerStub; + protected Migrator $migrator; + protected string $dateTimeFormat; + protected string $idpEntityId; + protected string $idpEntityIdHash; + protected string $idpMetadata; + protected string $idpMetadataHash; + protected string $spEntityId; + protected string $spMetadataHash; + protected string $userIdentifier; + protected string $userIdentifierHash; + protected string $userAttributes; + protected string $userAttributesHash; + protected Repository $repository; + protected \DateTimeImmutable $createdAt; + /** + * @var \PHPUnit\Framework\MockObject\Stub|Connection|Connection&\PHPUnit\Framework\MockObject\Stub + */ + protected $connectionStub; + protected string $spEntityIdHash; + protected string $spMetadata; + protected string $clientIpAddress; + + protected function setUp(): void + { + // For stubbing. + $this->connectionStub = $this->createStub(Connection::class); + $this->loggerStub = $this->createStub(LoggerInterface::class); + + // For real DB testing. + $connectionParameters = ConnectionParameters::DBAL_SQLITE_MEMORY; + $this->connection = new Connection($connectionParameters); + $this->migrator = new Migrator($this->connection, $this->loggerStub); + $moduleConfiguration = new ModuleConfiguration(); + $migrationsDirectory = $moduleConfiguration->getModuleSourceDirectory() . DIRECTORY_SEPARATOR . 'Stores' . + DIRECTORY_SEPARATOR . 'Data' . DIRECTORY_SEPARATOR . 'Authentication' . DIRECTORY_SEPARATOR . + 'DoctrineDbal' . DIRECTORY_SEPARATOR . 'Versioned' . DIRECTORY_SEPARATOR . 'Store' . DIRECTORY_SEPARATOR . + AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME; + $namespace = Store::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME; + + $this->migrator->runSetup(); + $this->migrator->runNonImplementedMigrationClasses($migrationsDirectory, $namespace); + + $this->repository = new Repository($this->connection, $this->loggerStub); + + $this->dateTimeFormat = DateTime::DEFAULT_FORMAT; + + $this->idpEntityId = 'idp-entity-id'; + $this->idpEntityIdHash = 'idp-entity-id-hash'; + + $this->idpMetadata = 'idp-metadata'; + $this->idpMetadataHash = 'idp-metadata-hash'; + + $this->spEntityId = 'sp-entity-id'; + $this->spEntityIdHash = 'sp-entity-id-hash'; + + $this->spMetadata = 'sp-metadata'; + $this->spMetadataHash = 'sp-metadata-hash'; + + $this->userIdentifier = 'user-identifier'; + $this->userIdentifierHash = 'user-identifier-hash'; + + $this->userAttributes = 'user-attributes'; + $this->userAttributesHash = 'user-attributes-hash'; + + $this->createdAt = new \DateTimeImmutable(); + $this->clientIpAddress = '123.123.123.123'; + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf( + Repository::class, + new Repository($this->connection, $this->loggerStub) + ); + } + + public function testCanInsertAndGetIdp(): array + { + $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt); + + $result = $this->repository->getIdp($this->idpEntityIdHash)->fetchAssociative(); + + $this->assertSame($this->idpEntityId, $result[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID]); + $this->assertSame( + $this->idpEntityIdHash, + $result[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256] + ); + $this->assertSame( + $this->createdAt->format($this->dateTimeFormat), + $result[Store\TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT] + ); + + return $result; + } + + public function testInsertIdpThrowsOnNonUniqueIdpEntityIdHash(): void + { + $this->expectException(StoreException::class); + + // Can't insert duplicate idp entity ID hash. + $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt); + $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt); + } + + public function testGetIdpThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getIdp($this->idpEntityIdHash); + } + + /** + * @depends testCanInsertAndGetIdp + */ + public function testCanInsertAndGetIdpVersion(array $idpResult): array + { + $idpId = (int)$idpResult[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ID]; + + $this->repository->insertIdpVersion($idpId, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt); + + $result = $this->repository->getIdpVersion($idpId, $this->idpMetadataHash)->fetchAssociative(); + + $this->assertSame($this->idpMetadata, $result[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA]); + $this->assertSame( + $this->idpMetadataHash, + $result[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256] + ); + $this->assertSame( + $this->createdAt->format($this->dateTimeFormat), + $result[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT] + ); + + return $result; + } + + public function testInsertIdpVersionThrowsOnNonUniqueIdpMetadataHash(): void + { + $this->expectException(StoreException::class); + // IdP Metadata Hash must be unique. + $this->repository->insertIdpVersion(1, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt); + $this->repository->insertIdpVersion(1, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt); + } + + public function testGetIdpVersionThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getIdpVersion(1, $this->idpMetadataHash); + } + + public function testCanInsertAndGetSp(): array + { + $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt); + + $result = $this->repository->getSp($this->spEntityIdHash)->fetchAssociative(); + + $this->assertSame($this->spEntityId, $result[Store\TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID]); + $this->assertSame( + $this->spEntityIdHash, + $result[Store\TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256] + ); + $this->assertSame( + $this->createdAt->format($this->dateTimeFormat), + $result[Store\TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT] + ); + + return $result; + } + + public function testInsertSpThrowsOnNonUniqueSpEntityIdHash(): void + { + $this->expectException(StoreException::class); + // SP Entity ID Hash must be unique. + $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt); + $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt); + } + + public function testGetSpThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getSp($this->spEntityIdHash); + } + + /** + * @depends testCanInsertAndGetSp + */ + public function testCanInsertAndGetSpVersion(array $spResult): array + { + $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID]; + + $this->repository->insertSpVersion($spId, $this->spMetadata, $this->spMetadataHash, $this->createdAt); + + $result = $this->repository->getSpVersion($spId, $this->spMetadataHash)->fetchAssociative(); + + $this->assertSame($this->spMetadata, $result[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA]); + $this->assertSame( + $this->spMetadataHash, + $result[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256] + ); + $this->assertSame( + $this->createdAt->format($this->dateTimeFormat), + $result[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT] + ); + + return $result; + } + + public function testInsertSpVersionThrowsOnNonUniqueMetadataHash(): void + { + $this->expectException(StoreException::class); + // SP metadata hash must be unique. + $this->repository->insertSpVersion(1, $this->spMetadata, $this->spMetadataHash, $this->createdAt); + $this->repository->insertSpVersion(1, $this->spMetadata, $this->spMetadataHash, $this->createdAt); + } + + public function testGetSpVersionThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getSpVersion(1, $this->spMetadataHash); + } + + public function testCanInsertAndGetUser(): array + { + $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt); + + $result = $this->repository->getUser($this->userIdentifierHash)->fetchAssociative(); + + $this->assertSame($this->userIdentifier, $result[Store\TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER]); + $this->assertSame( + $this->userIdentifierHash, + $result[Store\TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256] + ); + $this->assertSame( + $this->createdAt->format($this->dateTimeFormat), + $result[Store\TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT] + ); + + return $result; + } + + public function testInsertUserThrowsOnNonUniqueIdentifierHash(): void + { + $this->expectException(StoreException::class); + $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt); + $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt); + } + + public function testGetUserThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getUser($this->userIdentifierHash); + } + + /** + * @depends testCanInsertAndGetUser + */ + public function testCanInsertAndGetUserVersion(array $userResult): array + { + $userId = (int)$userResult[Store\TableConstants::TABLE_USER_COLUMN_NAME_ID]; + + $this->repository + ->insertUserVersion($userId, $this->userAttributes, $this->userAttributesHash, $this->createdAt); + + $result = $this->repository->getUserVersion($userId, $this->userAttributesHash)->fetchAssociative(); + + $this->assertSame( + $this->userAttributes, + $result[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES] + ); + $this->assertSame( + $this->userAttributesHash, + $result[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256] + ); + $this->assertSame( + $this->createdAt->format($this->dateTimeFormat), + $result[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT] + ); + + return $result; + } + + public function testInsertUserVersionThrowsOnNonUniqueAttributesHash(): void + { + $this->expectException(StoreException::class); + $this->repository + ->insertUserVersion(1, $this->userAttributes, $this->userAttributesHash, $this->createdAt); + $this->repository + ->insertUserVersion(1, $this->userAttributes, $this->userAttributesHash, $this->createdAt); + } + + public function testGetUserVersionThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getUserVersion(1, $this->userIdentifierHash); + } + + /** + * @depends testCanInsertAndGetIdpVersion + * @depends testCanInsertAndGetSpVersion + * @depends testCanInsertAndGetUserVersion + */ + public function testCanInsertAndGetIdpSpUserVersion( + array $idpVersionResult, + array $spVersionResult, + array $userVersionResult + ): array { + $idpVersionId = (int)$idpVersionResult[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID]; + $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID]; + $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt); + $result = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId) + ->fetchAssociative(); + + $this->assertSame( + $idpVersionId, + (int)$result[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID] + ); + $this->assertSame( + $spVersionId, + (int)$result[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID] + ); + $this->assertSame( + $userVersionId, + (int)$result[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID] + ); + $this->assertSame( + $this->createdAt->format($this->dateTimeFormat), + $result[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT] + ); + + return $result; + } + + public function testInsertIdpSpUserVersionThrowsOnNonUnique(): void + { + $this->expectException(StoreException::class); + $this->repository->insertIdpSpUserVersion(1, 1, 1, $this->createdAt); + $this->repository->insertIdpSpUserVersion(1, 1, 1, $this->createdAt); + } + + public function testGetIdpSpUserVersionThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getIdpSpUserVersion(1, 1, 1); + } + + /** + * @depends testCanInsertAndGetIdpSpUserVersion + */ + public function testCanInsertAuthenticationEvent(array $idpSpUserVersionResult): void + { + $idpSpUserVersionId = + (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID]; + $createdAt = $happenedAt = new \DateTimeImmutable(); + + $authenticationEventCounterQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $authenticationEventCounterQueryBuilder->select('COUNT(id) as authenticationEventCount') + ->from( + Store\TableConstants::TABLE_PREFIX . + $this->connection + ->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_AUTHENTICATION_EVENT) + ); + + $this->assertSame(0, (int)$authenticationEventCounterQueryBuilder->executeQuery()->fetchOne()); + + $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $happenedAt, null, $createdAt); + + $this->assertSame(1, (int)$authenticationEventCounterQueryBuilder->executeQuery()->fetchOne()); + } + + public function testInsertAuthenticationEventThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->insertAuthenticationEvent(1, $this->createdAt); + } + + public function testCanGetConnectedServiceProviders(): void + { + $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt); + $idpResult = $this->repository->getIdp($this->idpEntityIdHash)->fetchAssociative(); + $idpId = (int)$idpResult[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ID]; + $this->repository->insertIdpVersion($idpId, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt); + $idpVersionResult = $this->repository->getIdpVersion($idpId, $this->idpMetadataHash)->fetchAssociative(); + + $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt); + $spResult = $this->repository->getSp($this->spEntityIdHash)->fetchAssociative(); + $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID]; + $this->repository->insertSpVersion($spId, $this->spMetadata, $this->spMetadataHash, $this->createdAt); + $spVersionResult = $this->repository->getSpVersion($spId, $this->spMetadataHash)->fetchAssociative(); + + $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt); + $userResult = $this->repository->getUser($this->userIdentifierHash)->fetchAssociative(); + $userId = (int)$userResult[Store\TableConstants::TABLE_USER_COLUMN_NAME_ID]; + $this->repository + ->insertUserVersion($userId, $this->userAttributes, $this->userAttributesHash, $this->createdAt); + $userVersionResult = $this->repository->getUserVersion($userId, $this->userAttributesHash)->fetchAssociative(); + + $idpVersionId = (int)$idpVersionResult[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID]; + $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID]; + $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt); + $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId) + ->fetchAssociative(); + + $idpSpUserVersionId = + (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + + $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash); + + $this->assertCount(1, $resultArray); + $this->assertSame( + '1', + $resultArray[$this->spEntityId] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS] + ); + $this->assertSame( + $this->spMetadata, + $resultArray[$this->spEntityId] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] + ); + $this->assertSame( + $this->userAttributes, + $resultArray[$this->spEntityId] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] + ); + + $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash); + $this->assertCount(1, $resultArray); + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash); + $this->assertCount(1, $resultArray); + $this->assertSame( + '2', + $resultArray[$this->spEntityId] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS] + ); + $this->assertSame( + $this->spMetadata, + $resultArray[$this->spEntityId] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] + ); + $this->assertSame( + $this->userAttributes, + $resultArray[$this->spEntityId] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] + ); + + // Simulate another SP + $spEntityIdNew = $this->spEntityId . '-new'; + $spEntityIdHashNew = $this->spEntityIdHash . '-new'; + $spMetadataNew = $this->spMetadata . '-new'; + $spMetadataHashNew = $this->spMetadataHash . '-new'; + $this->repository->insertSp($spEntityIdNew, $spEntityIdHashNew, $this->createdAt); + $spResult = $this->repository->getSp($spEntityIdHashNew)->fetchAssociative(); + $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID]; + $this->repository->insertSpVersion($spId, $spMetadataNew, $spMetadataHashNew, $this->createdAt); + $spVersionResult = $this->repository->getSpVersion($spId, $spMetadataHashNew)->fetchAssociative(); + $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt); + $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId) + ->fetchAssociative(); + $idpSpUserVersionId = + (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + + $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash); + $this->assertCount(2, $resultArray); + $this->assertSame( + '1', + $resultArray[$spEntityIdNew] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS] + ); + $this->assertSame( + $spMetadataNew, + $resultArray[$spEntityIdNew] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] + ); + $this->assertSame( + $this->userAttributes, + $resultArray[$this->spEntityId] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] + ); + + // Simulate change in user attributes + $userAttributesNew = $this->userAttributes . '-new'; + $userAttributesHashNew = $this->userAttributesHash . '-new'; + $this->repository->insertUserVersion($userId, $userAttributesNew, $userAttributesHashNew, $this->createdAt); + $userVersionResult = $this->repository->getUserVersion($userId, $userAttributesHashNew)->fetchAssociative(); + $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt); + + $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId) + ->fetchAssociative(); + $idpSpUserVersionId = + (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash); + + $this->assertCount(2, $resultArray); + $this->assertSame( + '2', + $resultArray[$spEntityIdNew] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS] + ); + $this->assertSame( + $spMetadataNew, + $resultArray[$spEntityIdNew] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] + ); + // New SP with new user attributes version.. + $this->assertSame( + $userAttributesNew, + $resultArray[$spEntityIdNew] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] + ); + + // First SP still has old user attributes version... + $this->assertSame( + $this->userAttributes, + $resultArray[$this->spEntityId] + [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] + ); + } + + public function testGetConnectedServiceProvidersThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getConnectedServiceProviders($this->userIdentifierHash); + } + + public function testCanGetActivity(): void + { + $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt); + $idpResult = $this->repository->getIdp($this->idpEntityIdHash)->fetchAssociative(); + $idpId = (int)$idpResult[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ID]; + $this->repository->insertIdpVersion($idpId, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt); + $idpVersionResult = $this->repository->getIdpVersion($idpId, $this->idpMetadataHash)->fetchAssociative(); + + $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt); + $spResult = $this->repository->getSp($this->spEntityIdHash)->fetchAssociative(); + $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID]; + $this->repository->insertSpVersion($spId, $this->spMetadata, $this->spMetadataHash, $this->createdAt); + $spVersionResult = $this->repository->getSpVersion($spId, $this->spMetadataHash)->fetchAssociative(); + + $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt); + $userResult = $this->repository->getUser($this->userIdentifierHash)->fetchAssociative(); + $userId = (int)$userResult[Store\TableConstants::TABLE_USER_COLUMN_NAME_ID]; + $this->repository + ->insertUserVersion($userId, $this->userAttributes, $this->userAttributesHash, $this->createdAt); + $userVersionResult = $this->repository->getUserVersion($userId, $this->userAttributesHash)->fetchAssociative(); + + $idpVersionId = (int)$idpVersionResult[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID]; + $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID]; + $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt); + $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId) + ->fetchAssociative(); + + $idpSpUserVersionId = + (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + + $resultArray = $this->repository->getActivity($this->userIdentifierHash, 10, 0); + $this->assertCount(1, $resultArray); + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + $resultArray = $this->repository->getActivity($this->userIdentifierHash, 10, 0); + $this->assertCount(2, $resultArray); + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + $resultArray = $this->repository->getActivity($this->userIdentifierHash, 10, 0); + $this->assertCount(3, $resultArray); + + // Simulate another SP + $spEntityIdNew = $this->spEntityId . '-new'; + $spEntityIdHashNew = $this->spEntityIdHash . '-new'; + $spMetadataNew = $this->spMetadata . '-new'; + $spMetadataHashNew = $this->spMetadataHash . '-new'; + $this->repository->insertSp($spEntityIdNew, $spEntityIdHashNew, $this->createdAt); + $spResult = $this->repository->getSp($spEntityIdHashNew)->fetchAssociative(); + $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID]; + $this->repository->insertSpVersion($spId, $spMetadataNew, $spMetadataHashNew, $this->createdAt); + $spVersionResult = $this->repository->getSpVersion($spId, $spMetadataHashNew)->fetchAssociative(); + $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt); + $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId) + ->fetchAssociative(); + + $idpSpUserVersionId = + (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + $resultArray = $this->repository->getActivity($this->userIdentifierHash, 10, 0); + $this->assertCount(4, $resultArray); + + // Simulate a change in user attributes + } + + public function testGetActivityThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->getActivity($this->userIdentifierHash, 10, 0); + } + + public function testCanDeleteAuthenticationEventsOlderThan(): void + { + $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt); + $idpResult = $this->repository->getIdp($this->idpEntityIdHash)->fetchAssociative(); + $idpId = (int)$idpResult[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ID]; + $this->repository->insertIdpVersion($idpId, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt); + $idpVersionResult = $this->repository->getIdpVersion($idpId, $this->idpMetadataHash)->fetchAssociative(); + + $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt); + $spResult = $this->repository->getSp($this->spEntityIdHash)->fetchAssociative(); + $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID]; + $this->repository->insertSpVersion($spId, $this->spMetadata, $this->spMetadataHash, $this->createdAt); + $spVersionResult = $this->repository->getSpVersion($spId, $this->spMetadataHash)->fetchAssociative(); + + $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt); + $userResult = $this->repository->getUser($this->userIdentifierHash)->fetchAssociative(); + $userId = (int)$userResult[Store\TableConstants::TABLE_USER_COLUMN_NAME_ID]; + $this->repository + ->insertUserVersion($userId, $this->userAttributes, $this->userAttributesHash, $this->createdAt); + $userVersionResult = $this->repository->getUserVersion($userId, $this->userAttributesHash)->fetchAssociative(); + + $idpVersionId = (int)$idpVersionResult[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID]; + $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID]; + $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt); + $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId) + ->fetchAssociative(); + + $idpSpUserVersionId = + (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID]; + + $this->repository->insertAuthenticationEvent( + $idpSpUserVersionId, + $this->createdAt, + $this->clientIpAddress, + $this->createdAt + ); + + $resultArray = $this->repository->getActivity($this->userIdentifierHash, 10, 0); + $this->assertCount(1, $resultArray); + + $dateTimeInFuture = $this->createdAt->add(new \DateInterval('P1D')); + + $this->repository->deleteAuthenticationEventsOlderThan($dateTimeInFuture); + + $resultArray = $this->repository->getActivity($this->userIdentifierHash, 10, 0); + $this->assertCount(0, $resultArray); + } + + public function testDeleteAuthenticationEventsOlderThanThrowsOnInvalidDbal(): void + { + $this->connectionStub->method('dbal')->willThrowException(new \Exception('test')); + $repository = new Repository($this->connectionStub, $this->loggerStub); + $this->expectException(StoreException::class); + + $repository->deleteAuthenticationEventsOlderThan(new \DateTimeImmutable()); + } +} diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/StoreTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/StoreTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8910b95cb28b90cb2d2387dd057e7d13d929e110 --- /dev/null +++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/StoreTest.php @@ -0,0 +1,683 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned; + +use Doctrine\DBAL\Result; +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; +use SimpleSAML\Test\Module\accounting\Constants\RawRowResult; +use SimpleSAML\Test\Module\accounting\Constants\StateArrays; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000000CreateIdpTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000100CreateIdpVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000200CreateSpTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000300CreateSpVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000400CreateUserTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000500CreateUserVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000600CreateIdpSpUserVersionTable + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000700CreateAuthenticationEventTable + * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\HashDecoratedState + * @uses \SimpleSAML\Module\accounting\Helpers\HashHelper + * @uses \SimpleSAML\Module\accounting\Helpers\ArrayHelper + * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper + * @uses \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag + * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider + * @uses \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider + * @uses \SimpleSAML\Module\accounting\Entities\User + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawConnectedServiceProvider + * @uses \SimpleSAML\Module\accounting\Entities\Activity\Bag + * @uses \SimpleSAML\Module\accounting\Entities\Activity + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawActivity + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * @uses \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + * + * @psalm-suppress all + */ +class StoreTest extends TestCase +{ + protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub; + protected Migrator $migrator; + protected \PHPUnit\Framework\MockObject\Stub $factoryStub; + protected Connection $connection; + protected State $state; + protected Event $authenticationEvent; + protected Store\HashDecoratedState $hashDecoratedState; + /** + * @var \PHPUnit\Framework\MockObject\MockObject|Store\Repository + */ + protected $repositoryMock; + /** + * @var Result|\PHPUnit\Framework\MockObject\Stub + */ + protected $resultStub; + /** + * @var \PHPUnit\Framework\MockObject\MockObject|LoggerInterface + */ + protected $loggerMock; + + protected function setUp(): void + { + $connectionParams = ConnectionParameters::DBAL_SQLITE_MEMORY; + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn($connectionParams); + $this->moduleConfigurationStub->method('getUserIdAttributeName') + ->willReturn('hrEduPersonPersistentID'); + + $this->connection = new Connection($connectionParams); + + $this->loggerMock = $this->createMock(LoggerInterface::class); + + /** @psalm-suppress InvalidArgument */ + $this->migrator = new Migrator($this->connection, $this->loggerMock); + + $this->factoryStub = $this->createStub(Factory::class); + $this->factoryStub->method('buildConnection')->willReturn($this->connection); + $this->factoryStub->method('buildMigrator')->willReturn($this->migrator); + + $this->state = new State(StateArrays::FULL); + $this->authenticationEvent = new Event($this->state); + + $this->hashDecoratedState = new Store\HashDecoratedState($this->state); + $this->repositoryMock = $this->createMock(Store\Repository::class); + + $this->resultStub = $this->createStub(Result::class); + } + + public function testCanConstructInstance(): void + { + /** @psalm-suppress InvalidArgument */ + $this->assertInstanceOf( + Store::class, + new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ) + ); + } + + public function testCanBuildInstance(): void + { + /** @psalm-suppress InvalidArgument */ + $this->assertInstanceOf( + Store::class, + Store::build($this->moduleConfigurationStub, $this->loggerMock) + ); + } + + public function testCanPersistAuthenticationEvent(): void + { + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + $store->runSetup(); + + $idpCountQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $idpVersionCountQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $spCountQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $spVersionCountQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $userCountQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $userVersionCountQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $idpSpUserVersionCountQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + $authenticationEventCountQueryBuilder = $this->connection->dbal()->createQueryBuilder(); + + $idpCountQueryBuilder->select('COUNT(id) as idpCount')->from( + //'vds_idp' + $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_IDP + ) + ); + $idpVersionCountQueryBuilder->select('COUNT(id) as idpVersionCount')->from( + //'vds_idp_version' + $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_IDP_VERSION + ) + ); + $spCountQueryBuilder->select('COUNT(id) as spCount')->from( + //'vds_sp' + $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_SP + ) + ); + $spVersionCountQueryBuilder->select('COUNT(id) as spVersionCount')->from( + //'vds_sp_version' + $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_SP_VERSION + ) + ); + $userCountQueryBuilder->select('COUNT(id) as userCount')->from( + //'vds_user' + $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_USER + ) + ); + $userVersionCountQueryBuilder->select('COUNT(id) as userVersionCount')->from( + //'vds_user_version' + $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_USER_VERSION + ) + ); + $idpSpUserVersionCountQueryBuilder->select('COUNT(id) as idpSpUserVersionCount') + ->from( + //'vds_idp_sp_user_version' + $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_IDP_SP_USER_VERSION + ) + ); + $authenticationEventCountQueryBuilder->select('COUNT(id) as authenticationEventCount') + ->from( + //'vds_authentication_event' + $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_AUTHENTICATION_EVENT + ) + ); + + $this->assertSame(0, (int)$idpCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(0, (int)$idpVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(0, (int)$spCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(0, (int)$spVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(0, (int)$userCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(0, (int)$userVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(0, (int)$idpSpUserVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(0, (int)$authenticationEventCountQueryBuilder->executeQuery()->fetchOne()); + + $store->persist($this->authenticationEvent); + + $this->assertSame(1, (int)$idpCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$idpVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$spCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$spVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$userCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$userVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$idpSpUserVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$authenticationEventCountQueryBuilder->executeQuery()->fetchOne()); + + $store->persist($this->authenticationEvent); + + $this->assertSame(1, (int)$idpCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$idpVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$spCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$spVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$userCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$userVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(1, (int)$idpSpUserVersionCountQueryBuilder->executeQuery()->fetchOne()); + $this->assertSame(2, (int)$authenticationEventCountQueryBuilder->executeQuery()->fetchOne()); + } + + public function testResolveIdpIdThrowsOnFirstGetIdpFailure(): void + { + $this->repositoryMock->method('getIdp')->willThrowException(new \Exception('test')); + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + + $store->persist($this->authenticationEvent); + } + + public function testResolveIdpIdThrowsOnInsertAndGetIdpFailure(): void + { + $this->resultStub->method('fetchOne')->willReturn(false); + $this->repositoryMock->method('getIdp')->willReturn($this->resultStub); + $this->repositoryMock->method('insertIdp')->willThrowException(new \Exception('test')); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $this->loggerMock->expects($this->once())->method('warning'); + + $store->persist($this->authenticationEvent); + } + + public function testResolveIdpVersionIdThrowsOnFirstGetIdpVersionFailure(): void + { + $this->repositoryMock->method('getIdpVersion')->willThrowException(new \Exception('test')); + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + + $store->persist($this->authenticationEvent); + } + + public function testResolveIdpVersionIdThrowsOnInsertAndGetIdpVersionFailure(): void + { + $this->resultStub->method('fetchOne')->willReturn(false); + $this->repositoryMock->method('getIdpVersion')->willReturn($this->resultStub); + $this->repositoryMock->method('insertIdpVersion')->willThrowException(new \Exception('test')); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $this->loggerMock->expects($this->once())->method('warning'); + + $store->persist($this->authenticationEvent); + } + + public function testResolveSpIdThrowsOnFirstGetSpFailure(): void + { + $this->repositoryMock->method('getSp')->willThrowException(new \Exception('test')); + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + + $store->persist($this->authenticationEvent); + } + + public function testResolveSpIdThrowsOnInsertAndGetSpFailure(): void + { + $this->resultStub->method('fetchOne')->willReturn(false); + $this->repositoryMock->method('getSp')->willReturn($this->resultStub); + $this->repositoryMock->method('insertSp')->willThrowException(new \Exception('test')); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $this->loggerMock->expects($this->once())->method('warning'); + + $store->persist($this->authenticationEvent); + } + + public function testResolveSpVersionIdThrowsOnFirstGetSpVersionFailure(): void + { + $this->repositoryMock->method('getSpVersion')->willThrowException(new \Exception('test')); + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + + $store->persist($this->authenticationEvent); + } + + public function testResolveSpVersionIdThrowsOnInsertAndGetSpVersionFailure(): void + { + $this->resultStub->method('fetchOne')->willReturn(false); + $this->repositoryMock->method('getSpVersion')->willReturn($this->resultStub); + $this->repositoryMock->method('insertSpVersion')->willThrowException(new \Exception('test')); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $this->loggerMock->expects($this->once())->method('warning'); + + $store->persist($this->authenticationEvent); + } + + public function testResolveUserIdThrowsOnInvalidUserIdentifierValue(): void + { + $moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $moduleConfigurationStub->method('getUserIdAttributeName')->willReturn('invalid'); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(UnexpectedValueException::class); + + $store->persist($this->authenticationEvent); + } + + public function testResolveUserIdThrowsOnFirstGetUserFailure(): void + { + $this->repositoryMock->method('getUser')->willThrowException(new \Exception('test')); + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + + $store->persist($this->authenticationEvent); + } + + public function testResolveUserIdThrowsOnInsertAndGetUserFailure(): void + { + $this->resultStub->method('fetchOne')->willReturn(false); + $this->repositoryMock->method('getUser')->willReturn($this->resultStub); + $this->repositoryMock->method('insertUser')->willThrowException(new \Exception('test')); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $this->loggerMock->expects($this->once())->method('warning'); + + $store->persist($this->authenticationEvent); + } + + public function testResolveUserVersionIdThrowsOnFirstGetUserVersionFailure(): void + { + $this->repositoryMock->method('getUserVersion')->willThrowException(new \Exception('test')); + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + + $store->persist($this->authenticationEvent); + } + + public function testResolveUserVersionIdThrowsOnInsertAndGetUserVersionFailure(): void + { + $this->resultStub->method('fetchOne')->willReturn(false); + $this->repositoryMock->method('getUserVersion')->willReturn($this->resultStub); + $this->repositoryMock->method('insertUserVersion')->willThrowException(new \Exception('test')); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $this->loggerMock->expects($this->once())->method('warning'); + + $store->persist($this->authenticationEvent); + } + + public function testResolveIdpSpUserVersionIdThrowsOnFirstGetIdpSpUserVersionFailure(): void + { + $this->repositoryMock->method('getIdpSpUserVersion')->willThrowException(new \Exception('test')); + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + + $store->persist($this->authenticationEvent); + } + + public function testResolveIdpSpUserVersionIdThrowsOnInsertAndGetIdpSpUserVersionFailure(): void + { + $this->resultStub->method('fetchOne')->willReturn(false); + $this->repositoryMock->method('getIdpSpUserVersion')->willReturn($this->resultStub); + $this->repositoryMock->method('insertIdpSpUserVersion')->willThrowException(new \Exception('test')); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $this->loggerMock->expects($this->once())->method('warning'); + + $store->persist($this->authenticationEvent); + } + + public function testGetConnectedOrganizationsReturnsEmptyBagIfNoResults(): void + { + $this->repositoryMock->method('getConnectedServiceProviders')->willReturn([]); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $connectedServiceProviderBag = $store->getConnectedOrganizations('test'); + + $this->assertEmpty($connectedServiceProviderBag->getAll()); + } + + public function testCanGetConnectedOrganizationsBag(): void + { + $this->repositoryMock->method('getConnectedServiceProviders') + ->willReturn([RawRowResult::CONNECTED_ORGANIZATION]); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $connectedServiceProviderBag = $store->getConnectedOrganizations('test'); + + $this->assertNotEmpty($connectedServiceProviderBag->getAll()); + } + + public function testGetConnectedOrganizationsThrowsForInvalidResult(): void + { + $rawResult = RawRowResult::CONNECTED_ORGANIZATION; + unset($rawResult[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS]); + + $this->repositoryMock->method('getConnectedServiceProviders') + ->willReturn([$rawResult]); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $store->getConnectedOrganizations('test'); + } + + public function testGetActivityReturnsEmptyBagIfNoResults(): void + { + $this->repositoryMock->method('getActivity')->willReturn([]); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $activityBag = $store->getActivity('test', 10, 0); + + $this->assertEmpty($activityBag->getAll()); + } + + public function testCanGetActivityBag(): void + { + $this->repositoryMock->method('getActivity') + ->willReturn([RawRowResult::ACTIVITY]); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $activityBag = $store->getActivity('test', 10, 0); + + $this->assertNotEmpty($activityBag->getAll()); + } + + public function testGetActivityThrowsForInvalidResult(): void + { + $rawResult = RawRowResult::ACTIVITY; + unset($rawResult[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT]); + + $this->repositoryMock->method('getActivity') + ->willReturn([$rawResult]); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $this->expectException(StoreException::class); + $store->getActivity('test', 10, 0); + } + + public function testCanDeleteDataOlderThan(): void + { + $dateTime = new \DateTimeImmutable(); + + $this->repositoryMock->expects($this->once()) + ->method('deleteAuthenticationEventsOlderThan') + ->with($dateTime); + + /** @psalm-suppress InvalidArgument */ + $store = new Store( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $this->repositoryMock + ); + + $store->deleteDataOlderThan($dateTime); + } +} diff --git a/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTableTest.php b/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b56de0e42d4ff53ea3c72c71ff6d81fa7e67fb40 --- /dev/null +++ b/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTableTest.php @@ -0,0 +1,73 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable + * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + */ +class Version20220601000000CreateJobTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Version20220601000000CreateJobTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $connectionStub = $this->createStub(Connection::class); + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + + $connectionStub->method('dbal')->willReturn($dbalStub); + $dbalStub->method('createSchemaManager')->willReturn($schemaManagerStub); + $schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + + $migration = new Version20220601000000CreateJobTable($connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $connectionStub = $this->createStub(Connection::class); + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + + $connectionStub->method('dbal')->willReturn($dbalStub); + $dbalStub->method('createSchemaManager')->willReturn($schemaManagerStub); + $schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + + $migration = new Version20220601000000CreateJobTable($connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } +} diff --git a/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTableTest.php b/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTableTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0240fc6c4d035d518df04978980ba707761477c0 --- /dev/null +++ b/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTableTest.php @@ -0,0 +1,73 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations; + +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable + */ +class Version20220601000100CreateJobFailedTableTest extends TestCase +{ + protected Connection $connection; + protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager; + protected string $tableName; + + protected function setUp(): void + { + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->schemaManager = $this->connection->dbal()->createSchemaManager(); + $this->tableName = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB_FAILED); + } + + public function testCanRunMigration(): void + { + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + $migration = new Migrations\Version20220601000100CreateJobFailedTable($this->connection); + $migration->run(); + $this->assertTrue($this->schemaManager->tablesExist($this->tableName)); + $migration->revert(); + $this->assertFalse($this->schemaManager->tablesExist($this->tableName)); + } + + public function testRunThrowsMigrationException(): void + { + $connectionStub = $this->createStub(Connection::class); + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + + $connectionStub->method('dbal')->willReturn($dbalStub); + $dbalStub->method('createSchemaManager')->willReturn($schemaManagerStub); + $schemaManagerStub->method('createTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + + $migration = new Migrations\Version20220601000100CreateJobFailedTable($connectionStub); + $this->expectException(MigrationException::class); + $migration->run(); + } + + public function testRevertThrowsMigrationException(): void + { + $connectionStub = $this->createStub(Connection::class); + $dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class); + $schemaManagerStub = $this->createStub(AbstractSchemaManager::class); + + $connectionStub->method('dbal')->willReturn($dbalStub); + $dbalStub->method('createSchemaManager')->willReturn($schemaManagerStub); + $schemaManagerStub->method('dropTable') + ->willThrowException(new \Doctrine\DBAL\Exception('test')); + + $migration = new Migrations\Version20220601000100CreateJobFailedTable($connectionStub); + $this->expectException(MigrationException::class); + $migration->revert(); + } +} diff --git a/tests/src/Stores/Jobs/DoctrineDbal/Store/RawJobTest.php b/tests/src/Stores/Jobs/DoctrineDbal/Store/RawJobTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fa92aa61cf55074ef500e06aff7b025775bbdcf4 --- /dev/null +++ b/tests/src/Stores/Jobs/DoctrineDbal/Store/RawJobTest.php @@ -0,0 +1,127 @@ +<?php + +namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\Store; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\RawJob; +use SimpleSAML\Test\Module\accounting\Constants\StateArrays; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\RawJob + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity + * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + */ +class RawJobTest extends TestCase +{ + protected Event $authenticationEvent; + protected array $validRawRow; + protected \PHPUnit\Framework\MockObject\Stub $abstractPlatformStub; + + protected function setUp(): void + { + $this->abstractPlatformStub = $this->createStub(AbstractPlatform::class); + $this->authenticationEvent = new Event(new State(StateArrays::FULL)); + $this->validRawRow = [ + Store\TableConstants::COLUMN_NAME_ID => 1, + Store\TableConstants::COLUMN_NAME_PAYLOAD => serialize($this->authenticationEvent), + Store\TableConstants::COLUMN_NAME_TYPE => get_class($this->authenticationEvent), + Store\TableConstants::COLUMN_NAME_CREATED_AT => '2022-08-17 13:26:12', + ]; + } + + public function testCanInstantiateValidRawJob(): void + { + $abstractPlatform = new SqlitePlatform(); + $rawJob = new Store\RawJob($this->validRawRow, $abstractPlatform); + $this->assertSame($rawJob->getId(), $this->validRawRow[Store\TableConstants::COLUMN_NAME_ID]); + $this->assertEquals($rawJob->getPayload(), $this->authenticationEvent); + $this->assertSame($rawJob->getType(), $this->validRawRow[Store\TableConstants::COLUMN_NAME_TYPE]); + $this->assertInstanceOf(\DateTimeImmutable::class, $rawJob->getCreatedAt()); + } + + public function testThrowsOnEmptyColumn(): void + { + $invalidRawRow = $this->validRawRow; + unset($invalidRawRow[Store\TableConstants::COLUMN_NAME_ID]); + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress InvalidArgument */ + new RawJob($invalidRawRow, $this->abstractPlatformStub); + } + + public function testThrowsOnNonNumericId(): void + { + $invalidRawRow = $this->validRawRow; + $invalidRawRow[Store\TableConstants::COLUMN_NAME_ID] = 'a'; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress InvalidArgument */ + new RawJob($invalidRawRow, $this->abstractPlatformStub); + } + + public function testThrowsOnNonStringPayload(): void + { + $invalidRawRow = $this->validRawRow; + $invalidRawRow[Store\TableConstants::COLUMN_NAME_PAYLOAD] = 123; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress InvalidArgument */ + new RawJob($invalidRawRow, $this->abstractPlatformStub); + } + + public function testThrowsOnNonAbstractPayload(): void + { + $invalidRawRow = $this->validRawRow; + $invalidRawRow[Store\TableConstants::COLUMN_NAME_PAYLOAD] = serialize('abc'); + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress InvalidArgument */ + new Store\RawJob($invalidRawRow, $this->abstractPlatformStub); + } + + public function testThrowsOnNonStringType(): void + { + $invalidRawRow = $this->validRawRow; + $invalidRawRow[Store\TableConstants::COLUMN_NAME_TYPE] = 123; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress InvalidArgument */ + new Store\RawJob($invalidRawRow, $this->abstractPlatformStub); + } + + public function testThrowsOnNonStringCreatedAt(): void + { + $invalidRawRow = $this->validRawRow; + $invalidRawRow[Store\TableConstants::COLUMN_NAME_CREATED_AT] = 123; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress InvalidArgument */ + new RawJob($invalidRawRow, $this->abstractPlatformStub); + } + + public function testThrowsOnNonValidCreatedAt(): void + { + $invalidRawRow = $this->validRawRow; + $invalidRawRow[Store\TableConstants::COLUMN_NAME_CREATED_AT] = '123'; + + $this->expectException(UnexpectedValueException::class); + + /** @psalm-suppress InvalidArgument */ + new Store\RawJob($invalidRawRow, $this->abstractPlatformStub); + } +} diff --git a/tests/src/Stores/Jobs/DoctrineDbal/Store/RepositoryTest.php b/tests/src/Stores/Jobs/DoctrineDbal/Store/RepositoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3241e834c0cc1fe56c45b1fc1e8f33fdad015f35 --- /dev/null +++ b/tests/src/Stores/Jobs/DoctrineDbal/Store/RepositoryTest.php @@ -0,0 +1,233 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\Store; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; +use SimpleSAML\Module\accounting\Entities\GenericJob; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\Logger; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; +use SimpleSAML\Test\Module\accounting\Constants\StateArrays; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob + * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper + * @uses \SimpleSAML\Module\accounting\ModuleConfiguration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\RawJob + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity + * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * @uses \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + */ +class RepositoryTest extends TestCase +{ + protected ModuleConfiguration $moduleConfiguration; + protected Connection $connection; + protected \PHPUnit\Framework\MockObject\Stub $loggerServiceStub; + protected Migrator $migrator; + protected \PHPUnit\Framework\MockObject\Stub $factoryStub; + protected \PHPUnit\Framework\MockObject\Stub $payloadStub; + protected \PHPUnit\Framework\MockObject\Stub $jobStub; + protected Store $jobsStore; + protected string $jobsTableName; + + protected function setUp(): void + { + // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml). + $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php'); + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + + $this->loggerServiceStub = $this->createStub(Logger::class); + + /** @psalm-suppress InvalidArgument */ + $this->migrator = new Migrator($this->connection, $this->loggerServiceStub); + + $this->factoryStub = $this->createStub(Factory::class); + $this->factoryStub->method('buildConnection')->willReturn($this->connection); + $this->factoryStub->method('buildMigrator')->willReturn($this->migrator); + + $this->payloadStub = $this->createStub(AbstractPayload::class); + $this->jobStub = $this->createStub(GenericJob::class); + $this->jobStub->method('getPayload')->willReturn($this->payloadStub); + $this->jobStub->method('getType')->willReturn(GenericJob::class); + $this->jobStub->method('getCreatedAt')->willReturn(new \DateTimeImmutable()); + + /** @psalm-suppress InvalidArgument */ + $this->jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerServiceStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + + $this->jobsTableName = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB); + } + + public function testCanInsertAndGetJob(): void + { + /** @psalm-suppress InvalidArgument */ + $repository = new Repository($this->connection, $this->jobsTableName, $this->loggerServiceStub); + // Running setup will ensure that all migrations are ran. + $this->jobsStore->runSetup(); + + $this->assertNull($repository->getNext()); + + /** @psalm-suppress InvalidArgument */ + $repository->insert($this->jobStub); + + $this->assertNotNull($repository->getNext()); + } + + public function testInsertThrowsIfJobsStoreSetupNotRan(): void + { + /** @psalm-suppress InvalidArgument */ + $repository = new Repository($this->connection, $this->jobsTableName, $this->loggerServiceStub); + // Running setup will ensure that all migrations are ran. + //$this->jobsStore->runSetup(); + + $this->expectException(StoreException::class); + + /** @psalm-suppress InvalidArgument */ + $repository->insert($this->jobStub); + } + + public function testInsertThrowsForInvalidJobType(): void + { + /** @psalm-suppress InvalidArgument */ + $repository = new Repository($this->connection, $this->jobsTableName, $this->loggerServiceStub); + // Running setup will ensure that all migrations are ran. + $this->jobsStore->runSetup(); + + $this->expectException(StoreException::class); + + $invalidType = str_pad('abc', Store\TableConstants::COLUMN_TYPE_LENGTH + 1); + $jobStub = $this->createStub(GenericJob::class); + $jobStub->method('getPayload')->willReturn($this->payloadStub); + $jobStub->method('getType')->willReturn($invalidType); + $jobStub->method('getCreatedAt')->willReturn(new \DateTimeImmutable()); + + /** @psalm-suppress InvalidArgument */ + $repository->insert($jobStub); + } + + public function testGetNextThrowsIfJobsStoreSetupNotRan(): void + { + /** @psalm-suppress InvalidArgument */ + $repository = new Repository($this->connection, $this->jobsTableName, $this->loggerServiceStub); + // Running setup will ensure that all migrations are ran. + //$this->jobsStore->runSetup(); + + $this->expectException(StoreException::class); + + $repository->getNext(); + } + + public function testGetNextThrowsForInvalidJobType(): void + { + /** @psalm-suppress InvalidArgument */ + $repository = new Repository($this->connection, $this->jobsTableName, $this->loggerServiceStub); + // Running setup will ensure that all migrations are ran. + $this->jobsStore->runSetup(); + + $payloadStub = $this->createStub(AbstractPayload::class); + $jobStub = $this->createStub(AbstractJob::class); // Abstract classes can't be initialized.. + $jobStub->method('getPayload')->willReturn($payloadStub); + $jobStub->method('getType')->willReturn(AbstractJob::class); + $jobStub->method('getCreatedAt')->willReturn(new \DateTimeImmutable()); + + /** @psalm-suppress InvalidArgument */ + $repository->insert($jobStub); + + $this->expectException(StoreException::class); + + $repository->getNext(); + } + + public function testCanDeleteJob(): void + { + /** @psalm-suppress InvalidArgument */ + $repository = new Repository($this->connection, $this->jobsTableName, $this->loggerServiceStub); + // Running setup will ensure that all migrations are ran. + $this->jobsStore->runSetup(); + + $this->assertFalse($repository->delete(1)); + /** @psalm-suppress InvalidArgument */ + $repository->insert($this->jobStub); + $job = $repository->getNext(); + if ($job === null) { + throw new \Exception('Invalid job.'); + } + $jobId = $job->getId(); + if ($jobId === null) { + throw new \Exception('Invalid job ID.'); + } + $this->assertTrue($repository->delete($jobId)); + $this->assertFalse($repository->delete($jobId)); + } + + public function testDeleteThrowsWhenJobsStoreSetupNotRan(): void + { + /** @psalm-suppress InvalidArgument */ + $repository = new Repository($this->connection, $this->jobsTableName, $this->loggerServiceStub); + // Running setup will ensure that all migrations are ran. + //$this->jobsStore->runSetup(); + + $this->expectException(StoreException::class); + + $repository->delete(1); + } + + public function testCanGetSpecificJobType(): void + { + /** @psalm-suppress InvalidArgument */ + $repository = new Repository($this->connection, $this->jobsTableName, $this->loggerServiceStub); + // Running setup will ensure that all migrations are ran. + $this->jobsStore->runSetup(); + + $this->assertNull($repository->getNext()); + + /** @psalm-suppress InvalidArgument */ + $repository->insert($this->jobStub); + + $authenticationEvent = new Event(new State(StateArrays::FULL)); + $authenticationEventJob = new Event\Job($authenticationEvent); + + $repository->insert($authenticationEventJob); + + $this->assertInstanceOf(Event\Job::class, $repository->getNext(Event\Job::class)); + } + + public function testInitializationThrowsForInvalidJobsTableName(): void + { + $this->expectException(StoreException::class); + + /** @psalm-suppress InvalidArgument */ + new Repository($this->connection, 'invalid-table-name', $this->loggerServiceStub); + } +} diff --git a/tests/src/Stores/Jobs/DoctrineDbal/StoreTest.php b/tests/src/Stores/Jobs/DoctrineDbal/StoreTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3aca1188d80cec61b7f6cbbf24c225bd4c96736e --- /dev/null +++ b/tests/src/Stores/Jobs/DoctrineDbal/StoreTest.php @@ -0,0 +1,388 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\Authentication\State; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob; +use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload; +use SimpleSAML\Module\accounting\Entities\GenericJob; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\Logger; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory; +use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator; +use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; +use SimpleSAML\Test\Module\accounting\Constants\StateArrays; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store + * @covers \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore + * @uses \SimpleSAML\Module\accounting\ModuleConfiguration + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable + * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\RawJob + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository + * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable + * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity + * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * @uses \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + */ +class StoreTest extends TestCase +{ + protected ModuleConfiguration $moduleConfiguration; + protected \PHPUnit\Framework\MockObject\Stub $factoryStub; + protected Connection $connection; + protected \PHPUnit\Framework\MockObject\Stub $loggerStub; + protected Migrator $migrator; + protected \PHPUnit\Framework\MockObject\Stub $payloadStub; + protected \PHPUnit\Framework\MockObject\Stub $jobStub; + + protected function setUp(): void + { + // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml). + $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php'); + $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY); + + $this->loggerStub = $this->createStub(Logger::class); + + /** @psalm-suppress InvalidArgument */ + $this->migrator = new Migrator($this->connection, $this->loggerStub); + + $this->factoryStub = $this->createStub(Factory::class); + $this->factoryStub->method('buildConnection')->willReturn($this->connection); + $this->factoryStub->method('buildMigrator')->willReturn($this->migrator); + + $this->payloadStub = $this->createStub(AbstractPayload::class); + $this->jobStub = $this->createStub(GenericJob::class); + $this->jobStub->method('getPayload')->willReturn($this->payloadStub); + $this->jobStub->method('getType')->willReturn(GenericJob::class); + $this->jobStub->method('getCreatedAt')->willReturn(new \DateTimeImmutable()); + $this->jobStub->method('getId')->willReturn(1); + } + + public function testSetupDependsOnMigratorSetup(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + + $this->assertTrue($this->migrator->needsSetup()); + $this->assertTrue($jobsStore->needsSetup()); + + $jobsStore->runSetup(); + + $this->assertFalse($jobsStore->needsSetup()); + $this->assertFalse($this->migrator->needsSetup()); + } + + public function testSetupDependsOnMigrations(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + + // Run migrator setup beforehand, so it only depends on Store migrations setup + $this->migrator->runSetup(); + $this->assertTrue($jobsStore->needsSetup()); + + $jobsStore->runSetup(); + + $this->assertFalse($jobsStore->needsSetup()); + } + + public function testCanGetPrefixedTableNames(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + + $tableNameJobs = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB); + $tableNameFailedJobs = $this->connection->preparePrefixedTableName( + Store\TableConstants::TABLE_NAME_JOB_FAILED + ); + + $this->assertSame($tableNameJobs, $jobsStore->getPrefixedTableNameJobs()); + $this->assertSame($tableNameFailedJobs, $jobsStore->getPrefixedTableNameFailedJobs()); + } + + public function testCanBuildInstanceStatically(): void + { + $moduleConfiguration = $this->createStub(ModuleConfiguration::class); + $moduleConfiguration->method('getConnectionParameters') + ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY); + /** @psalm-suppress InvalidArgument */ + $this->assertInstanceOf(Store::class, Store::build($moduleConfiguration, $this->loggerStub)); + } + + public function testCanEnqueueJob(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + $jobsStore->runSetup(); + + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + $queryBuilder->select('COUNT(id) as jobsCount')->from($jobsStore->getPrefixedTableNameJobs()); + + $this->assertSame(0, (int) $queryBuilder->executeQuery()->fetchOne()); + + /** @psalm-suppress InvalidArgument */ + $jobsStore->enqueue($this->jobStub); + + $this->assertSame(1, (int) $queryBuilder->executeQuery()->fetchOne()); + + /** @psalm-suppress InvalidArgument */ + $jobsStore->enqueue($this->jobStub); + /** @psalm-suppress InvalidArgument */ + $jobsStore->enqueue($this->jobStub); + + $this->assertSame(3, (int) $queryBuilder->executeQuery()->fetchOne()); + } + + public function testEnqueueThrowsStoreExceptionOnNonSetupRun(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + // Don't run setup, so we get exception + //$jobsStore->runSetup(); + + $payloadStub = $this->createStub(AbstractPayload::class); + $jobStub = $this->createStub(AbstractJob::class); + $jobStub->method('getPayload')->willReturn($payloadStub); + + $this->expectException(StoreException::class); + + $jobsStore->enqueue($jobStub); + } + + public function testCanDequeueJob(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + $jobsStore->runSetup(); + + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + $queryBuilder->select('COUNT(id) as jobsCount')->from($jobsStore->getPrefixedTableNameJobs())->fetchOne(); + + $this->assertSame(0, (int) $queryBuilder->executeQuery()->fetchOne()); + + /** @psalm-suppress InvalidArgument */ + $jobsStore->enqueue($this->jobStub); + /** @psalm-suppress InvalidArgument */ + $jobsStore->enqueue($this->jobStub); + + $this->assertSame(2, (int) $queryBuilder->executeQuery()->fetchOne()); + + /** @psalm-suppress MixedArgument, UndefinedInterfaceMethod */ + $jobsStore->dequeue($this->jobStub->getType()); + + $this->assertSame(1, (int) $queryBuilder->executeQuery()->fetchOne()); + } + + public function testCanDequeueSpecificJobType(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + $jobsStore->runSetup(); + + $authenticationEvent = new Event(new State(StateArrays::FULL)); + $authenticationEventJob = new Event\Job($authenticationEvent); + + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + $queryBuilder->select('COUNT(id) as jobsCount')->from($jobsStore->getPrefixedTableNameJobs())->fetchOne(); + + $this->assertSame(0, (int) $queryBuilder->executeQuery()->fetchOne()); + + /** @psalm-suppress InvalidArgument */ + $jobsStore->enqueue($this->jobStub); + $jobsStore->enqueue($authenticationEventJob); + + $this->assertSame(2, (int) $queryBuilder->executeQuery()->fetchOne()); + + $this->assertInstanceOf(Event\Job::class, $jobsStore->dequeue(Event\Job::class)); + + $this->assertSame(1, (int) $queryBuilder->executeQuery()->fetchOne()); + + $this->assertNull($jobsStore->dequeue(Event::class)); + + $this->assertSame(1, (int) $queryBuilder->executeQuery()->fetchOne()); + } + + public function testDequeueThrowsWhenSetupNotRun(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); +// $jobsStore->runSetup(); + + $payloadStub = $this->createStub(AbstractPayload::class); + $jobStub = $this->createStub(AbstractJob::class); + $jobStub->method('getPayload')->willReturn($payloadStub); + + $this->expectException(StoreException::class); + + $jobsStore->dequeue('test-type'); + } + + public function testDequeueThrowsForJobWithInvalidId(): void + { + $repositoryStub = $this->createStub(Store\Repository::class); + $jobStub = $this->createStub(GenericJob::class); + $jobStub->method('getPayload')->willReturn($this->payloadStub); + $jobStub->method('getCreatedAt')->willReturn(new \DateTimeImmutable()); + $jobStub->method('getType')->willReturn(GenericJob::class); + $jobStub->method('getId')->willReturn(null); // Invalid ID value... + + $repositoryStub->method('getNext')->willReturn($jobStub); + + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $repositoryStub + ); + $jobsStore->runSetup(); + + $this->expectException(StoreException::class); + + /** @psalm-suppress MixedArgument, UndefinedInterfaceMethod */ + $jobsStore->dequeue($this->jobStub->getType()); + } + + public function testDequeThrowsAfterMaxDeleteAttempts(): void + { + $repositoryStub = $this->createStub(Store\Repository::class); + $repositoryStub->method('getNext')->willReturn($this->jobStub); + $repositoryStub->method('delete')->willReturn(false); + + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $repositoryStub + ); + $jobsStore->runSetup(); + + $this->expectException(StoreException::class); + + /** @psalm-suppress MixedArgument, UndefinedInterfaceMethod */ + $jobsStore->dequeue($this->jobStub->getType()); + } + + public function testCanContinueSearchingInCaseOfJobDeletion(): void + { + $repositoryStub = $this->createStub(Store\Repository::class); + $repositoryStub->method('getNext')->willReturn($this->jobStub); + $repositoryStub->method('delete')->willReturnOnConsecutiveCalls(false, true); + + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub, + $repositoryStub + ); + $jobsStore->runSetup(); + + /** @psalm-suppress MixedArgument, UndefinedInterfaceMethod */ + $this->assertNotNull($jobsStore->dequeue($this->jobStub->getType())); + } + + public function testCanMarkFailedJob(): void + { + /** @psalm-suppress InvalidArgument */ + $jobsStore = new Store( + $this->moduleConfiguration, + $this->loggerStub, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->factoryStub + ); + $jobsStore->runSetup(); + + $queryBuilder = $this->connection->dbal()->createQueryBuilder(); + $queryBuilder->select('COUNT(id) as jobsCount') + ->from($jobsStore->getPrefixedTableNameFailedJobs()) + ->fetchOne(); + + $this->assertSame(0, (int) $queryBuilder->executeQuery()->fetchOne()); + + /** @psalm-suppress InvalidArgument */ + $jobsStore->markFailedJob($this->jobStub); + + $this->assertSame(1, (int) $queryBuilder->executeQuery()->fetchOne()); + + /** @psalm-suppress InvalidArgument */ + $jobsStore->markFailedJob($this->jobStub); + + $this->assertSame(2, (int) $queryBuilder->executeQuery()->fetchOne()); + } +} diff --git a/tests/src/Stores/Jobs/PhpRedis/RedisStoreTest.php b/tests/src/Stores/Jobs/PhpRedis/RedisStoreTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f29ce0c1f90d22febd3fa47ed7f1cafef71e1352 --- /dev/null +++ b/tests/src/Stores/Jobs/PhpRedis/RedisStoreTest.php @@ -0,0 +1,377 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\PhpRedis; + +use Psr\Log\LoggerInterface; +use Redis; +use SimpleSAML\Module\accounting\Entities\GenericJob; +use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\Exceptions\StoreException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Stores\Jobs\PhpRedis\RedisStore; +use PHPUnit\Framework\TestCase; + +/** + * @covers \SimpleSAML\Module\accounting\Stores\Jobs\PhpRedis\RedisStore + * @uses \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + */ +class RedisStoreTest extends TestCase +{ + /** + * @var \PHPUnit\Framework\MockObject\Stub|ModuleConfiguration + */ + protected $moduleConfigurationStub; + /** + * @var \PHPUnit\Framework\MockObject\MockObject|LoggerInterface + */ + protected $loggerMock; + /** + * @var \PHPUnit\Framework\MockObject\MockObject|Redis + */ + protected $redisMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub|JobInterface + */ + protected $jobStub; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->redisMock = $this->createMock(Redis::class); + $this->jobStub = $this->createStub(JobInterface::class); + $this->jobStub->method('getType')->willReturn(GenericJob::class); + } + + public function testCanCreateInstance(): void + { + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertInstanceOf( + RedisStore::class, + new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ) + ); + } + + public function testThrowsIfHostConnectionParameterNotSet(): void + { + $this->expectException(InvalidConfigurationException::class); + + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertInstanceOf( + RedisStore::class, + new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ) + ); + } + + public function testThrowsOnConnectionError(): void + { + $this->expectException(StoreException::class); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->loggerMock->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('Error trying to connect to Redis DB.')); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('connect')->willThrowException(new \RedisException('test')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertInstanceOf( + RedisStore::class, + new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ) + ); + } + + public function testThrowsOnAuthError(): void + { + $this->expectException(StoreException::class); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->loggerMock->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('Error trying to set auth parameter for Redis.')); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample', 'auth' => 'test']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('auth')->willThrowException(new \RedisException('test')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertInstanceOf( + RedisStore::class, + new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ) + ); + } + + public function testThrowsOnSetPrefixOptionError(): void + { + $this->expectException(StoreException::class); + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->loggerMock->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('Could not set key prefix for Redis.')); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('setOption')->willThrowException(new \RedisException('test')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertInstanceOf( + RedisStore::class, + new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ) + ); + } + + public function testCanCallRPushMethod(): void + { + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('isConnected')->willReturn(true); + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->expects($this->once()) + ->method('rPush') + ->with($this->stringStartsWith(RedisStore::LIST_KEY_JOB), $this->isType('string')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore = new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore->enqueue($this->jobStub); + } + + public function testThrowsOnRPushError(): void + { + $this->expectException(StoreException::class); + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->loggerMock->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('Could not add job to Redis list.')); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('rPush')->willThrowException(new \RedisException('test')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore = new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore->enqueue($this->jobStub); + } + + public function testCanDequeueJob(): void + { + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('lPop') + ->willReturn(serialize($this->jobStub)); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore = new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ); + + /** @psalm-suppress PossiblyInvalidArgument, MixedArgument, PossiblyUndefinedMethod */ + $this->assertInstanceOf(JobInterface::class, $redisStore->dequeue($this->jobStub->getType())); + } + + public function testThrowsOnLPopError(): void + { + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('lPop') + ->willThrowException(new \RedisException('test')); + + $this->expectException(StoreException::class); + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->loggerMock->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('Could not pop job from Redis list.')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore = new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ); + + /** @psalm-suppress PossiblyInvalidArgument, MixedArgument, PossiblyUndefinedMethod */ + $redisStore->dequeue($this->jobStub->getType()); + } + + public function testThrowsIfNotAbleToDeserializeJobEntry(): void + { + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('lPop') + ->willReturn('invalid'); + + $this->expectException(StoreException::class); + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->loggerMock->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('Could not deserialize job entry which was available in Redis.')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore = new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ); + + // Suppress notice being raised using @ + /** @psalm-suppress PossiblyInvalidArgument, MixedArgument, PossiblyUndefinedMethod */ + @$redisStore->dequeue($this->jobStub->getType()); + } + + public function testCanMarkFailedJob(): void + { + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->expects($this->once()) + ->method('rPush') + ->with($this->stringStartsWith(RedisStore::LIST_KEY_JOB_FAILED), $this->isType('string')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore = new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore->markFailedJob($this->jobStub); + } + + public function testThrowsOnMarkingFailedJobError(): void + { + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->redisMock->method('rPush') + ->willThrowException(new \RedisException('test')); + + $this->expectException(StoreException::class); + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->loggerMock->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('Could not mark job as failed.')); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore = new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore->markFailedJob($this->jobStub); + } + + public function testSetupIsNotNeeded(): void + { + /** @psalm-suppress PossiblyUndefinedMethod, MixedMethodCall */ + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(['host' => 'sample']); + + /** @psalm-suppress PossiblyInvalidArgument */ + $redisStore = new RedisStore( + $this->moduleConfigurationStub, + $this->loggerMock, + null, + ModuleConfiguration\ConnectionType::MASTER, + $this->redisMock + ); + + /** @psalm-suppress PossiblyInvalidArgument */ + $this->assertFalse($redisStore->needsSetup()); + } +} diff --git a/tests/src/Trackers/Authentication/DoctrineDbal/Versioned/TrackerTest.php b/tests/src/Trackers/Authentication/DoctrineDbal/Versioned/TrackerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a6800b562b5af0da2cc92d900c5c5438929284fc --- /dev/null +++ b/tests/src/Trackers/Authentication/DoctrineDbal/Versioned/TrackerTest.php @@ -0,0 +1,216 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Activity; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider; +use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store; +use SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker + * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper + * @uses \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder + * @uses \SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory + * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator + * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store + * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository + * @uses \SimpleSAML\Module\accounting\Helpers\HashHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator + * @uses \SimpleSAML\Module\accounting\Stores\Bases\AbstractStore + * + * @psalm-suppress all + */ +class TrackerTest extends TestCase +{ + /** + * @var \PHPUnit\Framework\MockObject\Stub|ModuleConfiguration + */ + protected $moduleConfigurationStub; + /** + * @var \PHPUnit\Framework\MockObject\MockObject|LoggerInterface + */ + protected $loggerMock; + /** + * @var \PHPUnit\Framework\MockObject\MockObject|Store + */ + protected $dataStoreMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub|HelpersManager + */ + protected $helpersManagerStub; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->dataStoreMock = $this->createMock(Store::class); + $this->helpersManagerStub = $this->createStub(HelpersManager::class); + } + + /** + * @psalm-suppress PossiblyInvalidArgument + */ + public function testCanCreateInstance(): void + { + $this->assertInstanceOf( + Tracker::class, + new Tracker( + $this->moduleConfigurationStub, + $this->loggerMock, + ModuleConfiguration\ConnectionType::MASTER, + $this->helpersManagerStub, + $this->dataStoreMock + ) + ); + + $this->assertInstanceOf( + Tracker::class, + new Tracker($this->moduleConfigurationStub, $this->loggerMock) + ); + + $this->assertInstanceOf( + Tracker::class, + Tracker::build($this->moduleConfigurationStub, $this->loggerMock) + ); + } + + public function testProcessCallsPersistOnDataStore(): void + { + $authenticationEventStub = $this->createStub(Event::class); + + $this->dataStoreMock->expects($this->once()) + ->method('persist') + ->with($authenticationEventStub); + + /** @psalm-suppress PossiblyInvalidArgument */ + $tracker = new Tracker( + $this->moduleConfigurationStub, + $this->loggerMock, + ModuleConfiguration\ConnectionType::MASTER, + $this->helpersManagerStub, + $this->dataStoreMock + ); + + $tracker->process($authenticationEventStub); + } + + public function testSetupDependsOnDataStore(): void + { + $this->dataStoreMock->expects($this->exactly(2)) + ->method('needsSetup') + ->willReturn(true); + + $this->dataStoreMock->expects($this->once()) + ->method('runSetup'); + + /** @psalm-suppress PossiblyInvalidArgument */ + $tracker = new Tracker( + $this->moduleConfigurationStub, + $this->loggerMock, + ModuleConfiguration\ConnectionType::MASTER, + $this->helpersManagerStub, + $this->dataStoreMock + ); + + $this->assertTrue($tracker->needsSetup()); + + $tracker->runSetup(); + } + + public function testRunningSetupIfNotNeededLogsWarning(): void + { + $this->dataStoreMock->method('needsSetup') + ->willReturn(false); + + $this->loggerMock->expects($this->once()) + ->method('warning'); + + /** @psalm-suppress PossiblyInvalidArgument */ + $tracker = new Tracker( + $this->moduleConfigurationStub, + $this->loggerMock, + ModuleConfiguration\ConnectionType::MASTER, + $this->helpersManagerStub, + $this->dataStoreMock + ); + + $tracker->runSetup(); + } + + public function testGetConnectedServiceProviders(): void + { + $connectedOrganizationsBagStub = $this->createStub(ConnectedServiceProvider\Bag::class); + $this->dataStoreMock->expects($this->once()) + ->method('getConnectedOrganizations') + ->willReturn($connectedOrganizationsBagStub); + + /** @psalm-suppress PossiblyInvalidArgument */ + $tracker = new Tracker( + $this->moduleConfigurationStub, + $this->loggerMock, + ModuleConfiguration\ConnectionType::MASTER, + $this->helpersManagerStub, + $this->dataStoreMock + ); + + $this->assertInstanceOf( + ConnectedServiceProvider\Bag::class, + $tracker->getConnectedServiceProviders('test') + ); + } + + public function testGetActivity(): void + { + $activityBag = $this->createStub(Activity\Bag::class); + $this->dataStoreMock->expects($this->once()) + ->method('getActivity') + ->willReturn($activityBag); + + $tracker = new Tracker( + $this->moduleConfigurationStub, + $this->loggerMock, + ModuleConfiguration\ConnectionType::MASTER, + $this->helpersManagerStub, + $this->dataStoreMock + ); + + $this->assertInstanceOf( + Activity\Bag::class, + $tracker->getActivity('test', 10, 0) + ); + } + + public function testCanEnforceDataRetentionPolicy(): void + { + $retentionPolicy = new \DateInterval('P10D'); + + $this->dataStoreMock->expects($this->once()) + ->method('deleteDataOlderThan'); + + $tracker = new Tracker( + $this->moduleConfigurationStub, + $this->loggerMock, + ModuleConfiguration\ConnectionType::MASTER, + $this->helpersManagerStub, + $this->dataStoreMock + ); + + $tracker->enforceDataRetentionPolicy($retentionPolicy); + } +} diff --git a/tests/src/Trackers/Builders/AuthenticationDataTrackerBuilderTest.php b/tests/src/Trackers/Builders/AuthenticationDataTrackerBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8a27f1a4ab3c306b1e80521439bd976068449873 --- /dev/null +++ b/tests/src/Trackers/Builders/AuthenticationDataTrackerBuilderTest.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\accounting\Trackers\Builders; + +use Psr\Log\LoggerInterface; +use SimpleSAML\Module\accounting\Entities\Authentication\Event; +use SimpleSAML\Module\accounting\Exceptions\Exception; +use SimpleSAML\Module\accounting\ModuleConfiguration; +use SimpleSAML\Module\accounting\Services\HelpersManager; +use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface; +use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters; + +/** + * @covers \SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder + * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper + * @uses \SimpleSAML\Module\accounting\Services\HelpersManager + * + * @psalm-suppress all + */ +class AuthenticationDataTrackerBuilderTest extends TestCase +{ + /** + * @var \PHPUnit\Framework\MockObject\MockObject|LoggerInterface|LoggerInterface&\PHPUnit\Framework\MockObject\MockObject + */ + protected $loggerMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub|ModuleConfiguration|ModuleConfiguration&\PHPUnit\Framework\MockObject\Stub + */ + protected $moduleConfigurationStub; + + protected AuthenticationDataTrackerInterface $trackerStub; + protected HelpersManager $helpersManager; + + protected function setUp(): void + { + $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class); + $this->moduleConfigurationStub->method('getConnectionParameters') + ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY); + $this->loggerMock = $this->createMock(LoggerInterface::class); + + $this->helpersManager = new HelpersManager(); + + $this->trackerStub = new class implements AuthenticationDataTrackerInterface { + public static function build( + ModuleConfiguration $moduleConfiguration, + LoggerInterface $logger + ): AuthenticationDataTrackerInterface { + return new self(); + } + + public function process(Event $authenticationEvent): void + { + } + + public function needsSetup(): bool + { + return false; + } + + public function runSetup(): void + { + } + + public function enforceDataRetentionPolicy(\DateInterval $retentionPolicy): void + { + } + }; + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf( + AuthenticationDataTrackerBuilder::class, + new AuthenticationDataTrackerBuilder( + $this->moduleConfigurationStub, + $this->loggerMock, + $this->helpersManager + ) + ); + } + + public function testCanBuildAuthenticationDataTracker(): void + { + $authenticationDataTrackerBuilder = new AuthenticationDataTrackerBuilder( + $this->moduleConfigurationStub, + $this->loggerMock, + $this->helpersManager + ); + + $trackerClass = get_class($this->trackerStub); + + $this->assertInstanceOf($trackerClass, $authenticationDataTrackerBuilder->build($trackerClass)); + } + + public function testBuildThrowsForInvalidTrackerClass(): void + { + $authenticationDataTrackerBuilder = new AuthenticationDataTrackerBuilder( + $this->moduleConfigurationStub, + $this->loggerMock, + $this->helpersManager + ); + + $this->expectException(Exception::class); + + $authenticationDataTrackerBuilder->build('invalid'); + } +} diff --git a/www/assets/css/src/custom.css b/www/assets/css/src/custom.css new file mode 100644 index 0000000000000000000000000000000000000000..cb8acd7af26be658c0754190f87531e2f12e9fce --- /dev/null +++ b/www/assets/css/src/custom.css @@ -0,0 +1,42 @@ +.center { + text-align: center; +} + +.pagination { + display: inline-block; +} + +.pagination a { + color: black; + float: left; + padding: 8px 16px; + text-decoration: none; + transition: background-color .3s; + border: 1px solid #ddd; +} + +.pagination a.active { + background-color: #4CAF50; + color: white; + border: 1px solid #4CAF50; +} + +.pagination a:hover:not(.active) {background-color: #ddd;} + + +.accordion { + cursor: pointer; + transition: 0.4s; + border-bottom: 1px solid #cbcbcb; +} + +.active, .accordion:hover { + background-color: #ccc; +} + +.panel { + display: none; + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease-out; +} \ No newline at end of file diff --git a/www/assets/css/src/default.css b/www/assets/css/src/default.css new file mode 100644 index 0000000000000000000000000000000000000000..ee7abd9765e507356aa3afcb2ed9507649390a60 --- /dev/null +++ b/www/assets/css/src/default.css @@ -0,0 +1,424 @@ +:root { + /* */ + --main-text-c: #181E3C; + --main-bg-c: #F5F5F5; + + --heading-text-c: #5B6186; + + --caption-bg-c: #F0F2F9; + + --table-border: #F0F2F9; + + --nav-bg-c: #F8F8FC; + + --button-bg-c: #4859B2; + --button-text-c: #ffffff; + + --link-c: #4859B2; + + --main-font-family: Raleway; + --main-font-weight: 500; + --mainfont-size: 18pt; + + --table-font-size: 18pt; + + --large-font-size: 24px; +} + +/* raleway-300 - latin-ext_latin */ +@font-face { + font-family: 'Raleway'; + font-style: normal; + font-weight: 300; + src: local(''), + url('fonts/raleway-v28-latin-ext_latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('fonts/raleway-v28-latin-ext_latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + } + /* raleway-regular - latin-ext_latin */ + @font-face { + font-family: 'Raleway'; + font-style: normal; + font-weight: 400; + src: local(''), + url('fonts/raleway-v28-latin-ext_latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('fonts/raleway-v28-latin-ext_latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + } + /* raleway-500 - latin-ext_latin */ + @font-face { + font-family: 'Raleway'; + font-style: normal; + font-weight: 500; + src: local(''), + url('fonts/raleway-v28-latin-ext_latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('fonts/raleway-v28-latin-ext_latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + } + /* raleway-600 - latin-ext_latin */ + @font-face { + font-family: 'Raleway'; + font-style: normal; + font-weight: 600; + src: local(''), + url('fonts/raleway-v28-latin-ext_latin-600.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('fonts/raleway-v28-latin-ext_latin-600.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + } + +/* ======================= */ +/* mobile-first body */ +/* ======================= */ + +body { + display: grid; + grid-template-rows: 4em auto auto auto auto; + grid-template-columns: 1fr 18fr 1fr; + grid-template-areas: + "header header header" + "nav nav nav" + ". banner ." + ". main ." + "footer footer footer"; + min-height: 100vh; + font-size: var(--main-font-size); + font-family: var(--main-font-family); + font-weight: var(--main-font-weight); + margin: 0; +} + +/* ======================= */ +/* header and logo */ +/* ======================= */ + +header { + grid-area: header; + background-color: var(--nav-bg-c); + color: var(--link-c); + display: flex; + text-align: center; + width:100%; + z-index:999; + flex-direction: row; + align-items: center; + justify-content: flex-start; +} + +#logo { + margin-left: 2em; +} + +/* ======================= */ +/* Nav with hamburger menu */ +/* ======================= */ + +#nav{ + grid-area: nav; +} + +nav { + width: 100%; + padding: 1em; + text-align: left; + display:none; + background-color: var(--nav-bg-c); +} + +nav ul { + margin: 0; + padding: 0; + list-style: none; + display: inline-flex; + flex-direction: column; + flex-wrap: wrap; + align-items: left; + justify-content: flex-end; +} + +nav li { + margin-left: 0.5em; + margin-right: 0.5em; + color: var(--alt-color); +} + +nav > ul > li { + font-variant: normal; + display: flex; + flex-direction: row; + align-items: center; + margin-top: 0.5em; +} + +nav > ul > li > a { + color: var(--banner-font-family); + text-decoration: none; + display: flex; + flex-direction: row; + justify-items: center; +} + +.nav-toggle { + display:none; +} + +.nav-toggle:checked ~ nav { + display: block; +} + +.nav-toggle:checked ~ div { + display: none; +} + +.nav-toggle-label { + margin-right: 1em; + margin-left: auto; + display: flex; + height: 100%; + align-items: center; + align-self: flex-end; +} + +.nav-toggle:not(:checked) ~ .nav-toggle-label { + margin-left: auto; +} + +.nav-toggle-label span, +.nav-toggle-label span:before, +.nav-toggle-label span:after { + display: block; + background: var(--link-c);; + height: 2px; + width: 2em; + position: relative; +} + +.nav-toggle-label span:before, +.nav-toggle-label span:after { + content: ''; + position: absolute; +} + +.nav-toggle-label span:before { + bottom: 7px; +} + +.nav-toggle-label span:after { + top: 7px; +} + +.navicon { + display: inline-grid; + justify-content: center; + align-content: center; + width: 2em; +} + +/* ======================= */ +/* Banner */ +/* ======================= */ +#banner { + grid-area: banner; +} + +#banner div { + color: var(--link-c); + background-color: var(--nav-bg-c); + margin: 2em; + padding-left: 1em; + padding-right: 1em; + padding-bottom: 1em; + padding-top: 2.5em; + + border-width: 1px; + border-style: solid; + border-color: var(--link-c); +} + +/* ======================= */ +/* Main */ +/* ======================= */ + +#main{ + grid-area: main; +} + +/* ======================= */ +/* Table */ +/* ======================= */ + +table { + margin:1em; + padding:1em; + margin-right: auto; + font-size: var(--table-font-size); + width: 100%; +} + +th { + text-align:left; + padding:0.5em; + word-wrap: break-word; +} + +td { + text-align:left; + padding-left:0.5em; + padding-right:0.5em; + padding-top:1.25em; + word-wrap: break-word; + border-top: solid 1px var(--table-border); +} + +th a { + text-decoration:none; +} + +td a { + text-decoration: none; +} + +.dropdown { + float:right; +} + +/* ======================= */ +/* dropdown-box */ +/* ======================= */ + +.dropdown-toggle { + display: none; +} + +.dropdown-container { + padding:0em; + border:none; + display:table-cell; +} + +.dropdown-label { + float: right; + height: 1.25em; + position: relative; + top: -1.5em; +} + +/* +.dropdown-toggle:checked ~ .dropdown-label:first-of-type { + border: 3px solid black; +} +*/ + +.dropdown-box { + background-color: var(--caption-bg-c); + display:none; +} + +.dropdown-toggle:checked ~ div.dropdown-box:first-of-type { + display: block; +} + +/* ======================= */ +/* Footer */ +/* ======================= */ +footer { + display: flex; + text-align: center; + width:100%; + z-index:999; + flex-direction: row; + align-items: center; + justify-content: flex-start; + grid-area: footer; + color: var(--link-c); + background-color: var(--nav-bg-c); +} + +footer div { + margin: 2em; +} + +/* ======================= */ +/* desktop provisions */ +/* ======================= */ +@media only screen and (min-width: 768px) { + body { + display: grid; + grid-template-rows: 4em auto auto auto; + grid-template-columns: 3fr 0.5fr 16fr 0.5fr; + grid-template-areas: + "header header header header" + "nav . banner ." + "nav . main ." + "nav footer footer footer"; + min-height: 100vh; + font-size: var(--body-font-size); + font-family: var(--main-font-family); + font-weight: var(--main-font-weight); + margin: 0; + } + + header { + background-color: var(--main-bg-c); + } + + #nav { + background-color: var(--nav-bg-c); + } + + nav { + width: 100%; + padding: 0em; + margin-left: 0em; + } + + nav > ul { + width: 100%; + } + + nav > ul > li { + font-variant: normal; + font-size: 90%; + display: flex; + flex-direction: row; + align-items: center; + padding-left: 1em; + } + + nav > ul > li > a { + width: 100%; + } + + nav > ul > li > a > span > img { + display: inline; + } + + .nav-toggle ~ nav { + display: block; + } + + .nav-toggle-label { + display: none; + } + + /* ======================= */ + /* pagination */ + /* ======================= */ + .center { + text-align: center; + } + + .pagination { + display: inline-block; + } + + .pagination a { + color: black; + float: left; + padding: 8px 16px; + text-decoration: none; + transition: background-color .3s; + border: 1px solid #ddd; + } + + .pagination a.active { + background-color: #4CAF50; + color: white; + border: 1px solid #4CAF50; + } +} \ No newline at end of file diff --git a/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-300.woff b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-300.woff new file mode 100644 index 0000000000000000000000000000000000000000..dc1fdae677d4997655cbbe569035de51f2283ee4 Binary files /dev/null and b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-300.woff differ diff --git a/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-300.woff2 b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-300.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5c74b981a826de2286fead05d18752c0cd0fe787 Binary files /dev/null and b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-300.woff2 differ diff --git a/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-500.woff b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-500.woff new file mode 100644 index 0000000000000000000000000000000000000000..90f9b9a12c51cd868aca772a613e2340384b0dc6 Binary files /dev/null and b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-500.woff differ diff --git a/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-500.woff2 b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..902e197fceb48075a90088328383c444f32e9a19 Binary files /dev/null and b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-500.woff2 differ diff --git a/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-600.woff b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-600.woff new file mode 100644 index 0000000000000000000000000000000000000000..7f9315eb18875ac0612c71f34cd4acbb161bb41f Binary files /dev/null and b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-600.woff differ diff --git a/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-600.woff2 b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..977bba2f6a116b7e0f3896d067e3abd0df9a34b6 Binary files /dev/null and b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-600.woff2 differ diff --git a/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-regular.woff b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..067a3fda39dc8080c117a8c8540438b43b106516 Binary files /dev/null and b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-regular.woff differ diff --git a/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-regular.woff2 b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fb296a5863312faceb7d1f5af3ec9899744757d5 Binary files /dev/null and b/www/assets/css/src/fonts/raleway-v28-latin-ext_latin-regular.woff2 differ diff --git a/www/assets/css/src/icons/activity.svg b/www/assets/css/src/icons/activity.svg new file mode 100644 index 0000000000000000000000000000000000000000..d209dc1609b1d3e69ffb65a66f1a9bcdfc5ba8bc --- /dev/null +++ b/www/assets/css/src/icons/activity.svg @@ -0,0 +1,3 @@ +<svg width="26" height="20" viewBox="0 0 26 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M12.9974 12.4207L14.2807 9.5332L17.1682 8.24987L14.2807 6.96654L12.9974 4.07904L11.7141 6.96654L8.82656 8.24987L11.7141 9.5332L12.9974 12.4207ZM3.60573 16.679C3.13906 16.679 2.74045 16.5138 2.4099 16.1832C2.07934 15.8526 1.91406 15.4638 1.91406 15.0165V2.03737C1.91406 1.5707 2.07934 1.17209 2.4099 0.841537C2.74045 0.510981 3.13906 0.345703 3.60573 0.345703H22.3891C22.8557 0.345703 23.2543 0.510981 23.5849 0.841537C23.9155 1.17209 24.0807 1.5707 24.0807 2.03737V15.0165C24.0807 15.4638 23.9155 15.8526 23.5849 16.1832C23.2543 16.5138 22.8557 16.679 22.3891 16.679H3.60573ZM3.60573 15.3665H22.3891C22.4863 15.3665 22.5738 15.3276 22.6516 15.2499C22.7293 15.1721 22.7682 15.0943 22.7682 15.0165V2.03737C22.7682 1.94015 22.7293 1.85751 22.6516 1.78945C22.5738 1.7214 22.4863 1.68737 22.3891 1.68737H3.60573C3.50851 1.68737 3.42101 1.7214 3.34323 1.78945C3.26545 1.85751 3.22656 1.94015 3.22656 2.03737V15.0165C3.22656 15.0943 3.26545 15.1721 3.34323 15.2499C3.42101 15.3276 3.50851 15.3665 3.60573 15.3665ZM3.22656 15.3665V1.68737V15.3665ZM1.2724 19.5957C1.07795 19.5957 0.917535 19.5325 0.791146 19.4061C0.664757 19.2797 0.601562 19.1193 0.601562 18.9249C0.601562 18.7499 0.664757 18.5992 0.791146 18.4728C0.917535 18.3464 1.07795 18.2832 1.2724 18.2832H24.7224C24.9168 18.2832 25.0773 18.3464 25.2036 18.4728C25.33 18.5992 25.3932 18.7596 25.3932 18.954C25.3932 19.129 25.33 19.2797 25.2036 19.4061C25.0773 19.5325 24.9168 19.5957 24.7224 19.5957H1.2724Z" fill="black"/> +</svg> diff --git a/www/assets/css/src/icons/conn-orgs.svg b/www/assets/css/src/icons/conn-orgs.svg new file mode 100644 index 0000000000000000000000000000000000000000..9b497e85140b71c7a2c504e3a5c73ff0829bede0 --- /dev/null +++ b/www/assets/css/src/icons/conn-orgs.svg @@ -0,0 +1,3 @@ +<svg width="24" height="18" viewBox="0 0 24 18" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.3557 13.0833H12.6682V7.3375L15.1474 9.875L16.0807 8.9125L11.9682 4.82917L7.8849 8.9125L8.8474 9.875L11.3557 7.3375V13.0833ZM2.60573 17.75C2.13906 17.75 1.74045 17.5847 1.4099 17.2542C1.07934 16.9236 0.914062 16.525 0.914062 16.0583V1.94167C0.914062 1.475 1.07934 1.07639 1.4099 0.745834C1.74045 0.415278 2.13906 0.25 2.60573 0.25H21.3891C21.8557 0.25 22.2543 0.415278 22.5849 0.745834C22.9155 1.07639 23.0807 1.475 23.0807 1.94167V16.0583C23.0807 16.525 22.9155 16.9236 22.5849 17.2542C22.2543 17.5847 21.8557 17.75 21.3891 17.75H2.60573ZM2.60573 16.4375H21.3891C21.4863 16.4375 21.5738 16.3986 21.6516 16.3208C21.7293 16.2431 21.7682 16.1556 21.7682 16.0583V1.94167C21.7682 1.84444 21.7293 1.75694 21.6516 1.67917C21.5738 1.60139 21.4863 1.5625 21.3891 1.5625H2.60573C2.50851 1.5625 2.42101 1.60139 2.34323 1.67917C2.26545 1.75694 2.22656 1.84444 2.22656 1.94167V16.0583C2.22656 16.1556 2.26545 16.2431 2.34323 16.3208C2.42101 16.3986 2.50851 16.4375 2.60573 16.4375ZM2.22656 16.4375V1.5625V16.4375Z" fill="black"/> +</svg> diff --git a/www/assets/css/src/icons/download.svg b/www/assets/css/src/icons/download.svg new file mode 100644 index 0000000000000000000000000000000000000000..f0243211ab7be4c7e6ee8b90acc70f08d12672b0 --- /dev/null +++ b/www/assets/css/src/icons/download.svg @@ -0,0 +1,4 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="32" height="32" rx="2" fill="#3C4A94"/> +<path d="M7.93333 25.9999C7.4 25.9999 6.94444 25.811 6.56667 25.4333C6.18889 25.0555 6 24.5999 6 24.0666V19.9333H7.5V24.0666C7.5 24.1777 7.54444 24.2777 7.63333 24.3666C7.72222 24.4555 7.82222 24.4999 7.93333 24.4999H24.0667C24.1778 24.4999 24.2778 24.4555 24.3667 24.3666C24.4556 24.2777 24.5 24.1777 24.5 24.0666V19.9333H26V24.0666C26 24.5999 25.8111 25.0555 25.4333 25.4333C25.0556 25.811 24.6 25.9999 24.0667 25.9999H7.93333ZM16 20.9999L10.4667 15.4666L11.5667 14.3999L15.2333 18.0666V5.7666H16.7667V18.0666L20.4333 14.3999L21.5333 15.4666L16 20.9999Z" fill="white"/> +</svg> diff --git a/www/assets/css/src/icons/dropdown.svg b/www/assets/css/src/icons/dropdown.svg new file mode 100644 index 0000000000000000000000000000000000000000..e71599db091853b8eca5d8339b99850ffda0700a --- /dev/null +++ b/www/assets/css/src/icons/dropdown.svg @@ -0,0 +1,3 @@ +<svg width="16" height="10" viewBox="0 0 16 10" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.00013 9.56644L0.466797 1.99977L1.53346 0.933105L8.00013 7.39977L14.4668 0.933105L15.5335 2.03311L8.00013 9.56644Z" fill="#5B6186"/> +</svg> diff --git a/www/assets/css/src/icons/fppp-logo.svg b/www/assets/css/src/icons/fppp-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..c546a9c4efa2b3c82e5e93e7f6e225d891abdf0b --- /dev/null +++ b/www/assets/css/src/icons/fppp-logo.svg @@ -0,0 +1,37 @@ +<svg width="195" height="35" viewBox="0 0 195 35" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M39.2445 16V4.05909H47.2668V6.09409H41.5655V9.17182H46.3082V11.0555H41.5655V16H39.2445ZM52.1952 16.1682C51.4888 16.1682 50.8497 16.0505 50.2779 15.815C49.7061 15.5683 49.2184 15.2376 48.8147 14.8227C48.4111 14.4079 48.0972 13.9258 47.8729 13.3764C47.6599 12.827 47.5534 12.2495 47.5534 11.6441C47.5534 11.0162 47.6599 10.4276 47.8729 9.87818C48.086 9.31758 48.3943 8.82985 48.7979 8.415C49.2016 7.98894 49.6893 7.65258 50.2611 7.40591C50.8441 7.15924 51.4944 7.03591 52.212 7.03591C52.9296 7.03591 53.5687 7.15924 54.1293 7.40591C54.7011 7.65258 55.1832 7.98333 55.5757 8.39818C55.9793 8.81303 56.282 9.29515 56.4838 9.84454C56.6969 10.3939 56.8034 10.9658 56.8034 11.56C56.8034 11.7058 56.7978 11.8459 56.7866 11.9805C56.7866 12.115 56.7754 12.2271 56.7529 12.3168H49.9416C49.9752 12.6644 50.0593 12.9727 50.1938 13.2418C50.3284 13.5109 50.5022 13.7408 50.7152 13.9314C50.9282 14.122 51.1693 14.2677 51.4384 14.3686C51.7075 14.4695 51.9878 14.52 52.2793 14.52C52.7278 14.52 53.1482 14.4135 53.5407 14.2005C53.9443 13.9762 54.219 13.6847 54.3647 13.3259L56.2988 13.8641C55.9737 14.5368 55.4523 15.0918 54.7347 15.5291C54.0284 15.9552 53.1819 16.1682 52.1952 16.1682ZM54.4825 10.8536C54.4264 10.1921 54.1797 9.66515 53.7425 9.27273C53.3164 8.86909 52.7951 8.66727 52.1784 8.66727C51.8757 8.66727 51.5897 8.72333 51.3207 8.83545C51.0628 8.93636 50.8329 9.08212 50.6311 9.27273C50.4293 9.46333 50.2611 9.69318 50.1266 9.96227C50.0032 10.2314 49.9304 10.5285 49.9079 10.8536H54.4825ZM64.5504 16V14.6209C64.2589 15.1142 63.8833 15.4955 63.4236 15.7645C62.9639 16.0336 62.4313 16.1682 61.8258 16.1682C61.2204 16.1682 60.6598 16.0505 60.144 15.815C59.6283 15.5795 59.1854 15.2544 58.8154 14.8395C58.4454 14.4247 58.1539 13.9426 57.9408 13.3932C57.739 12.8326 57.6381 12.2327 57.6381 11.5936C57.6381 10.9545 57.7334 10.3603 57.924 9.81091C58.1258 9.26151 58.4005 8.77939 58.7481 8.36454C59.0957 7.9497 59.5105 7.62454 59.9927 7.38909C60.4748 7.15364 60.9961 7.03591 61.5568 7.03591C62.2183 7.03591 62.8069 7.19288 63.3227 7.50682C63.8384 7.82076 64.2477 8.23 64.5504 8.73455V3.72273H66.804V16H64.5504ZM64.5504 10.6686C64.3598 10.1641 64.0458 9.75485 63.6086 9.44091C63.1825 9.11576 62.734 8.95318 62.2631 8.95318C61.9268 8.95318 61.6184 9.03167 61.3381 9.18864C61.0578 9.33439 60.8168 9.53061 60.6149 9.77727C60.4131 10.0239 60.2561 10.3098 60.144 10.635C60.0319 10.9602 59.9758 11.2965 59.9758 11.6441C59.9758 12.0141 60.0375 12.3561 60.1608 12.67C60.2842 12.9839 60.4524 13.2586 60.6654 13.4941C60.8896 13.7295 61.1475 13.9145 61.439 14.0491C61.7305 14.1836 62.0501 14.2509 62.3977 14.2509C62.6219 14.2509 62.8461 14.2173 63.0704 14.15C63.2946 14.0715 63.5021 13.965 63.6927 13.8305C63.8945 13.6959 64.0683 13.5389 64.214 13.3595C64.371 13.1802 64.4831 12.9839 64.5504 12.7709V10.6686ZM72.9223 16.1682C72.2159 16.1682 71.5768 16.0505 71.005 15.815C70.4332 15.5683 69.9455 15.2376 69.5418 14.8227C69.1382 14.4079 68.8243 13.9258 68.6 13.3764C68.387 12.827 68.2805 12.2495 68.2805 11.6441C68.2805 11.0162 68.387 10.4276 68.6 9.87818C68.8131 9.31758 69.1214 8.82985 69.525 8.415C69.9287 7.98894 70.4164 7.65258 70.9882 7.40591C71.5712 7.15924 72.2215 7.03591 72.9391 7.03591C73.6567 7.03591 74.2958 7.15924 74.8564 7.40591C75.4282 7.65258 75.9103 7.98333 76.3028 8.39818C76.7064 8.81303 77.0091 9.29515 77.2109 9.84454C77.424 10.3939 77.5305 10.9658 77.5305 11.56C77.5305 11.7058 77.5249 11.8459 77.5137 11.9805C77.5137 12.115 77.5024 12.2271 77.48 12.3168H70.6687C70.7023 12.6644 70.7864 12.9727 70.9209 13.2418C71.0555 13.5109 71.2293 13.7408 71.4423 13.9314C71.6553 14.122 71.8964 14.2677 72.1655 14.3686C72.4346 14.4695 72.7149 14.52 73.0064 14.52C73.4549 14.52 73.8753 14.4135 74.2678 14.2005C74.6714 13.9762 74.9461 13.6847 75.0918 13.3259L77.0259 13.8641C76.7008 14.5368 76.1794 15.0918 75.4618 15.5291C74.7555 15.9552 73.909 16.1682 72.9223 16.1682ZM75.2096 10.8536C75.1535 10.1921 74.9068 9.66515 74.4696 9.27273C74.0435 8.86909 73.5221 8.66727 72.9055 8.66727C72.6028 8.66727 72.3168 8.72333 72.0478 8.83545C71.7899 8.93636 71.56 9.08212 71.3582 9.27273C71.1564 9.46333 70.9882 9.69318 70.8537 9.96227C70.7303 10.2314 70.6574 10.5285 70.635 10.8536H75.2096ZM84.1507 9.13818C83.4667 9.14939 82.8557 9.28394 82.3175 9.54182C81.7793 9.78848 81.3925 10.1641 81.157 10.6686V16H78.9034V7.18727H80.972V9.07091C81.129 8.76818 81.314 8.49909 81.527 8.26364C81.7401 8.01697 81.9699 7.80394 82.2166 7.62454C82.4632 7.44515 82.7099 7.31061 82.9566 7.22091C83.2145 7.12 83.4611 7.06955 83.6966 7.06955C83.8199 7.06955 83.9096 7.06955 83.9657 7.06955C84.0329 7.06955 84.0946 7.07515 84.1507 7.08636V9.13818ZM90.6742 16V14.7723C90.3378 15.2432 89.9117 15.5964 89.396 15.8318C88.8802 16.0561 88.314 16.1682 87.6973 16.1682C87.2713 16.1682 86.8732 16.1009 86.5032 15.9664C86.1332 15.8206 85.8137 15.6244 85.5446 15.3777C85.2755 15.1198 85.0625 14.8227 84.9055 14.4864C84.7598 14.15 84.6869 13.78 84.6869 13.3764C84.6869 12.9279 84.7878 12.5298 84.9896 12.1823C85.1914 11.8235 85.4661 11.5208 85.8137 11.2741C86.1613 11.0274 86.5593 10.8424 87.0078 10.7191C87.4563 10.5958 87.9328 10.5341 88.4373 10.5341C88.9082 10.5341 89.3287 10.5733 89.6987 10.6518C90.0687 10.7303 90.3658 10.82 90.5901 10.9209V10.4164C90.5901 9.84455 90.4219 9.40167 90.0855 9.08773C89.7604 8.76258 89.2726 8.6 88.6223 8.6C88.1514 8.6 87.6917 8.68409 87.2432 8.85227C86.7948 9.02045 86.3351 9.26712 85.8642 9.59227L85.1746 8.16273C85.7128 7.80394 86.2846 7.52924 86.8901 7.33864C87.5067 7.13682 88.157 7.03591 88.841 7.03591C90.1304 7.03591 91.117 7.35545 91.801 7.99455C92.4961 8.62242 92.8437 9.51379 92.8437 10.6686V16H90.6742ZM90.2201 13.78C90.4667 13.5894 90.5901 13.3764 90.5901 13.1409V12.1318C90.3098 12.0197 90.0126 11.9356 89.6987 11.8795C89.3848 11.8235 89.0876 11.7955 88.8073 11.7955C88.5607 11.7955 88.314 11.8235 88.0673 11.8795C87.8319 11.9356 87.6189 12.0253 87.4282 12.1486C87.2489 12.2608 87.1031 12.4065 86.991 12.5859C86.8789 12.7541 86.8228 12.9559 86.8228 13.1914C86.8228 13.5838 86.9685 13.9033 87.2601 14.15C87.5628 14.3967 87.9216 14.52 88.3364 14.52C88.6728 14.52 89.0148 14.4639 89.3623 14.3518C89.7211 14.2285 90.007 14.0379 90.2201 13.78ZM95.4429 16V9.22227H94.1311V7.18727H95.4429V4.32818H97.7638V7.18727H99.7988V9.22227H97.7638V16H95.4429ZM104.867 16.1682C104.161 16.1682 103.522 16.0505 102.95 15.815C102.378 15.5683 101.89 15.2376 101.487 14.8227C101.083 14.4079 100.769 13.9258 100.545 13.3764C100.332 12.827 100.225 12.2495 100.225 11.6441C100.225 11.0162 100.332 10.4276 100.545 9.87818C100.758 9.31758 101.066 8.82985 101.47 8.415C101.873 7.98894 102.361 7.65258 102.933 7.40591C103.516 7.15924 104.166 7.03591 104.884 7.03591C105.601 7.03591 106.24 7.15924 106.801 7.40591C107.373 7.65258 107.855 7.98333 108.247 8.39818C108.651 8.81303 108.954 9.29515 109.156 9.84454C109.369 10.3939 109.475 10.9658 109.475 11.56C109.475 11.7058 109.47 11.8459 109.458 11.9805C109.458 12.115 109.447 12.2271 109.425 12.3168H102.613C102.647 12.6644 102.731 12.9727 102.866 13.2418C103 13.5109 103.174 13.7408 103.387 13.9314C103.6 14.122 103.841 14.2677 104.11 14.3686C104.379 14.4695 104.66 14.52 104.951 14.52C105.4 14.52 105.82 14.4135 106.212 14.2005C106.616 13.9762 106.891 13.6847 107.037 13.3259L108.971 13.8641C108.645 14.5368 108.124 15.0918 107.407 15.5291C106.7 15.9552 105.854 16.1682 104.867 16.1682ZM107.154 10.8536C107.098 10.1921 106.852 9.66515 106.414 9.27273C105.988 8.86909 105.467 8.66727 104.85 8.66727C104.547 8.66727 104.262 8.72333 103.992 8.83545C103.735 8.93636 103.505 9.08212 103.303 9.27273C103.101 9.46333 102.933 9.69318 102.798 9.96227C102.675 10.2314 102.602 10.5285 102.58 10.8536H107.154ZM117.222 16V14.6209C116.931 15.1142 116.555 15.4955 116.095 15.7645C115.636 16.0336 115.103 16.1682 114.498 16.1682C113.892 16.1682 113.332 16.0505 112.816 15.815C112.3 15.5795 111.857 15.2544 111.487 14.8395C111.117 14.4247 110.826 13.9426 110.613 13.3932C110.411 12.8326 110.31 12.2327 110.31 11.5936C110.31 10.9545 110.405 10.3603 110.596 9.81091C110.798 9.26151 111.072 8.77939 111.42 8.36454C111.767 7.9497 112.182 7.62454 112.664 7.38909C113.147 7.15364 113.668 7.03591 114.229 7.03591C114.89 7.03591 115.479 7.19288 115.994 7.50682C116.51 7.82076 116.919 8.23 117.222 8.73455V3.72273H119.476V16H117.222ZM117.222 10.6686C117.032 10.1641 116.718 9.75485 116.28 9.44091C115.854 9.11576 115.406 8.95318 114.935 8.95318C114.599 8.95318 114.29 9.03167 114.01 9.18864C113.73 9.33439 113.489 9.53061 113.287 9.77727C113.085 10.0239 112.928 10.3098 112.816 10.635C112.704 10.9602 112.648 11.2965 112.648 11.6441C112.648 12.0141 112.709 12.3561 112.833 12.67C112.956 12.9839 113.124 13.2586 113.337 13.4941C113.561 13.7295 113.819 13.9145 114.111 14.0491C114.402 14.1836 114.722 14.2509 115.069 14.2509C115.294 14.2509 115.518 14.2173 115.742 14.15C115.966 14.0715 116.174 13.965 116.364 13.8305C116.566 13.6959 116.74 13.5389 116.886 13.3595C117.043 13.1802 117.155 12.9839 117.222 12.7709V10.6686ZM125.766 16V4.05909H130.828C131.378 4.05909 131.882 4.17682 132.342 4.41227C132.813 4.63651 133.217 4.93924 133.553 5.32045C133.889 5.69045 134.153 6.11651 134.343 6.59864C134.534 7.06955 134.629 7.54606 134.629 8.02818C134.629 8.53273 134.54 9.02606 134.36 9.50818C134.181 9.97909 133.929 10.3995 133.603 10.7695C133.278 11.1395 132.886 11.4367 132.426 11.6609C131.967 11.8852 131.462 11.9973 130.913 11.9973H128.087V16H125.766ZM128.087 9.96227H130.778C131.204 9.96227 131.557 9.78848 131.838 9.44091C132.129 9.09333 132.275 8.62242 132.275 8.02818C132.275 7.72545 132.23 7.45636 132.14 7.22091C132.051 6.97424 131.933 6.76682 131.787 6.59864C131.641 6.43045 131.468 6.30712 131.266 6.22864C131.075 6.13894 130.879 6.09409 130.677 6.09409H128.087V9.96227ZM139.965 16.1682C139.259 16.1682 138.62 16.0505 138.048 15.815C137.476 15.5683 136.988 15.2376 136.585 14.8227C136.181 14.4079 135.867 13.9258 135.643 13.3764C135.43 12.827 135.323 12.2495 135.323 11.6441C135.323 11.0162 135.43 10.4276 135.643 9.87818C135.856 9.31758 136.164 8.82985 136.568 8.415C136.971 7.98894 137.459 7.65258 138.031 7.40591C138.614 7.15924 139.264 7.03591 139.982 7.03591C140.699 7.03591 141.339 7.15924 141.899 7.40591C142.471 7.65258 142.953 7.98333 143.346 8.39818C143.749 8.81303 144.052 9.29515 144.254 9.84454C144.467 10.3939 144.573 10.9658 144.573 11.56C144.573 11.7058 144.568 11.8459 144.556 11.9805C144.556 12.115 144.545 12.2271 144.523 12.3168H137.711C137.745 12.6644 137.829 12.9727 137.964 13.2418C138.098 13.5109 138.272 13.7408 138.485 13.9314C138.698 14.122 138.939 14.2677 139.208 14.3686C139.477 14.4695 139.758 14.52 140.049 14.52C140.498 14.52 140.918 14.4135 141.311 14.2005C141.714 13.9762 141.989 13.6847 142.135 13.3259L144.069 13.8641C143.744 14.5368 143.222 15.0918 142.505 15.5291C141.798 15.9552 140.952 16.1682 139.965 16.1682ZM142.252 10.8536C142.196 10.1921 141.95 9.66515 141.512 9.27273C141.086 8.86909 140.565 8.66727 139.948 8.66727C139.646 8.66727 139.36 8.72333 139.091 8.83545C138.833 8.93636 138.603 9.08212 138.401 9.27273C138.199 9.46333 138.031 9.69318 137.896 9.96227C137.773 10.2314 137.7 10.5285 137.678 10.8536H142.252ZM151.193 9.13818C150.51 9.14939 149.898 9.28394 149.36 9.54182C148.822 9.78848 148.435 10.1641 148.2 10.6686V16H145.946V7.18727H148.015V9.07091C148.172 8.76818 148.357 8.49909 148.57 8.26364C148.783 8.01697 149.013 7.80394 149.259 7.62454C149.506 7.44515 149.753 7.31061 149.999 7.22091C150.257 7.12 150.504 7.06955 150.739 7.06955C150.863 7.06955 150.952 7.06955 151.008 7.06955C151.076 7.06955 151.137 7.07515 151.193 7.08636V9.13818ZM155.78 16.1682C155.04 16.1682 154.317 16.0505 153.611 15.815C152.904 15.5795 152.299 15.2432 151.794 14.8059L152.635 13.3932C153.173 13.7744 153.695 14.0659 154.199 14.2677C154.715 14.4583 155.225 14.5536 155.73 14.5536C156.178 14.5536 156.531 14.4695 156.789 14.3014C157.047 14.1332 157.176 13.8921 157.176 13.5782C157.176 13.2642 157.025 13.0344 156.722 12.8886C156.419 12.7429 155.926 12.5747 155.242 12.3841C154.67 12.2271 154.182 12.0758 153.779 11.93C153.375 11.7842 153.05 11.6217 152.803 11.4423C152.557 11.2517 152.377 11.0386 152.265 10.8032C152.153 10.5565 152.097 10.265 152.097 9.92864C152.097 9.48015 152.181 9.07651 152.349 8.71773C152.529 8.35894 152.775 8.05621 153.089 7.80955C153.403 7.55167 153.768 7.35545 154.182 7.22091C154.608 7.08636 155.068 7.01909 155.561 7.01909C156.223 7.01909 156.84 7.11439 157.411 7.305C157.994 7.49561 158.527 7.80394 159.009 8.23L158.101 9.59227C157.653 9.25591 157.215 9.00924 156.789 8.85227C156.374 8.6953 155.959 8.61682 155.545 8.61682C155.163 8.61682 154.844 8.6953 154.586 8.85227C154.328 9.00924 154.199 9.26151 154.199 9.60909C154.199 9.76606 154.227 9.895 154.283 9.99591C154.351 10.0968 154.451 10.1865 154.586 10.265C154.721 10.3435 154.894 10.422 155.107 10.5005C155.332 10.5677 155.606 10.6406 155.931 10.7191C156.537 10.8761 157.053 11.033 157.479 11.19C157.916 11.347 158.269 11.5264 158.538 11.7282C158.819 11.93 159.02 12.1655 159.144 12.4345C159.278 12.6924 159.346 13.0064 159.346 13.3764C159.346 14.2397 159.026 14.9236 158.387 15.4282C157.748 15.9215 156.879 16.1682 155.78 16.1682ZM164.88 16.1682C164.162 16.1682 163.518 16.0448 162.946 15.7982C162.374 15.5515 161.886 15.2208 161.483 14.8059C161.09 14.3798 160.787 13.8921 160.574 13.3427C160.361 12.7933 160.255 12.2159 160.255 11.6105C160.255 10.9938 160.361 10.4108 160.574 9.86136C160.787 9.31197 161.09 8.82985 161.483 8.415C161.886 7.98894 162.374 7.65258 162.946 7.40591C163.518 7.15924 164.162 7.03591 164.88 7.03591C165.597 7.03591 166.237 7.15924 166.797 7.40591C167.369 7.65258 167.851 7.98894 168.244 8.415C168.647 8.82985 168.956 9.31197 169.169 9.86136C169.382 10.4108 169.488 10.9938 169.488 11.6105C169.488 12.2159 169.382 12.7933 169.169 13.3427C168.956 13.8921 168.653 14.3798 168.26 14.8059C167.868 15.2208 167.386 15.5515 166.814 15.7982C166.242 16.0448 165.597 16.1682 164.88 16.1682ZM162.576 11.6105C162.576 12.0029 162.632 12.3617 162.744 12.6868C162.867 13.0008 163.03 13.2755 163.232 13.5109C163.445 13.7464 163.691 13.9314 163.972 14.0659C164.252 14.1892 164.555 14.2509 164.88 14.2509C165.205 14.2509 165.508 14.1892 165.788 14.0659C166.068 13.9314 166.309 13.7464 166.511 13.5109C166.724 13.2755 166.887 12.9952 166.999 12.67C167.122 12.3448 167.184 11.9861 167.184 11.5936C167.184 11.2124 167.122 10.8592 166.999 10.5341C166.887 10.2089 166.724 9.92864 166.511 9.69318C166.309 9.45773 166.068 9.27833 165.788 9.155C165.508 9.02045 165.205 8.95318 164.88 8.95318C164.555 8.95318 164.252 9.02045 163.972 9.155C163.691 9.28955 163.445 9.47455 163.232 9.71C163.03 9.94545 162.867 10.2258 162.744 10.5509C162.632 10.8761 162.576 11.2292 162.576 11.6105ZM179.2 16H176.946V11.0555C176.946 10.3491 176.823 9.83333 176.576 9.50818C176.329 9.18303 175.987 9.02045 175.55 9.02045C175.326 9.02045 175.096 9.0653 174.861 9.155C174.625 9.2447 174.401 9.37364 174.188 9.54182C173.986 9.69879 173.801 9.88939 173.633 10.1136C173.465 10.3379 173.341 10.5845 173.263 10.8536V16H171.009V7.18727H173.044V8.81864C173.369 8.25803 173.84 7.82076 174.457 7.50682C175.074 7.19288 175.769 7.03591 176.542 7.03591C177.092 7.03591 177.54 7.13682 177.888 7.33864C178.235 7.54045 178.505 7.80394 178.695 8.12909C178.886 8.45424 179.015 8.82424 179.082 9.23909C179.16 9.65394 179.2 10.0744 179.2 10.5005V16ZM186.541 16V14.7723C186.205 15.2432 185.779 15.5964 185.263 15.8318C184.747 16.0561 184.181 16.1682 183.564 16.1682C183.138 16.1682 182.74 16.1009 182.37 15.9664C182 15.8206 181.681 15.6244 181.412 15.3777C181.142 15.1198 180.929 14.8227 180.772 14.4864C180.627 14.15 180.554 13.78 180.554 13.3764C180.554 12.9279 180.655 12.5298 180.857 12.1823C181.058 11.8235 181.333 11.5208 181.681 11.2741C182.028 11.0274 182.426 10.8424 182.875 10.7191C183.323 10.5958 183.8 10.5341 184.304 10.5341C184.775 10.5341 185.196 10.5733 185.566 10.6518C185.936 10.7303 186.233 10.82 186.457 10.9209V10.4164C186.457 9.84455 186.289 9.40167 185.952 9.08773C185.627 8.76258 185.14 8.6 184.489 8.6C184.018 8.6 183.559 8.68409 183.11 8.85227C182.662 9.02045 182.202 9.26712 181.731 9.59227L181.042 8.16273C181.58 7.80394 182.152 7.52924 182.757 7.33864C183.374 7.13682 184.024 7.03591 184.708 7.03591C185.997 7.03591 186.984 7.35545 187.668 7.99455C188.363 8.62242 188.711 9.51379 188.711 10.6686V16H186.541ZM186.087 13.78C186.334 13.5894 186.457 13.3764 186.457 13.1409V12.1318C186.177 12.0197 185.88 11.9356 185.566 11.8795C185.252 11.8235 184.955 11.7955 184.674 11.7955C184.428 11.7955 184.181 11.8235 183.934 11.8795C183.699 11.9356 183.486 12.0253 183.295 12.1486C183.116 12.2608 182.97 12.4065 182.858 12.5859C182.746 12.7541 182.69 12.9559 182.69 13.1914C182.69 13.5838 182.835 13.9033 183.127 14.15C183.43 14.3967 183.788 14.52 184.203 14.52C184.54 14.52 184.882 14.4639 185.229 14.3518C185.588 14.2285 185.874 14.0379 186.087 13.78ZM190.702 3.72273H192.956V16H190.702V3.72273Z" fill="#4859B2"/> +<path d="M39.281 31.5044V20.8041H43.742C44.2142 20.8041 44.6462 20.9046 45.0381 21.1055C45.44 21.2964 45.7816 21.5576 46.0629 21.8892C46.3543 22.2107 46.5803 22.5774 46.7411 22.9894C46.9018 23.3913 46.9822 23.8032 46.9822 24.2252C46.9822 24.6673 46.9069 25.0943 46.7562 25.5062C46.6054 25.9181 46.3894 26.2849 46.1081 26.6064C45.8368 26.9279 45.5053 27.1841 45.1134 27.375C44.7316 27.5659 44.3046 27.6613 43.8324 27.6613H40.6374V31.5044H39.281ZM40.6374 26.4557H43.7571C44.0384 26.4557 44.2896 26.4004 44.5106 26.2899C44.7417 26.1693 44.9376 26.0086 45.0984 25.8076C45.2591 25.5966 45.3847 25.3555 45.4751 25.0842C45.5656 24.8129 45.6108 24.5266 45.6108 24.2252C45.6108 23.9137 45.5555 23.6223 45.445 23.3511C45.3445 23.0798 45.2039 22.8437 45.023 22.6427C44.8522 22.4418 44.6462 22.2861 44.4051 22.1755C44.174 22.065 43.9279 22.0098 43.6666 22.0098H40.6374V26.4557ZM52.6194 24.7979C51.9563 24.818 51.3685 24.9888 50.8561 25.3103C50.3538 25.6318 49.9971 26.0789 49.7861 26.6516V31.5044H48.4599V23.6374H49.6957V25.461C49.967 24.9184 50.3236 24.4814 50.7657 24.1498C51.2078 23.8082 51.68 23.6173 52.1824 23.5771C52.2828 23.5771 52.3682 23.5771 52.4386 23.5771C52.5089 23.5771 52.5692 23.5822 52.6194 23.5922V24.7979ZM57.1944 31.6551C56.6016 31.6551 56.059 31.5446 55.5667 31.3235C55.0844 31.1025 54.6675 30.8061 54.3158 30.4344C53.9742 30.0626 53.708 29.6306 53.5171 29.1383C53.3262 28.646 53.2307 28.1285 53.2307 27.586C53.2307 27.0334 53.3262 26.5109 53.5171 26.0186C53.718 25.5263 53.9893 25.0943 54.3309 24.7225C54.6826 24.3508 55.0995 24.0544 55.5818 23.8333C56.0741 23.6123 56.6116 23.5018 57.1944 23.5018C57.7771 23.5018 58.3146 23.6123 58.8069 23.8333C59.2993 24.0544 59.7162 24.3508 60.0578 24.7225C60.4095 25.0943 60.6807 25.5263 60.8716 26.0186C61.0726 26.5109 61.1731 27.0334 61.1731 27.586C61.1731 28.1285 61.0776 28.646 60.8867 29.1383C60.6958 29.6306 60.4245 30.0626 60.0729 30.4344C59.7212 30.8061 59.2993 31.1025 58.8069 31.3235C58.3247 31.5446 57.7871 31.6551 57.1944 31.6551ZM54.5871 27.601C54.5871 28.013 54.6524 28.3948 54.783 28.7464C54.9237 29.0981 55.1096 29.4045 55.3406 29.6658C55.5818 29.927 55.8581 30.1329 56.1695 30.2837C56.4911 30.4344 56.8327 30.5097 57.1944 30.5097C57.5561 30.5097 57.8926 30.4344 58.2041 30.2837C58.5256 30.1329 58.8069 29.927 59.0481 29.6658C59.2892 29.3945 59.4751 29.083 59.6057 28.7314C59.7464 28.3697 59.8167 27.9828 59.8167 27.5709C59.8167 27.169 59.7464 26.7922 59.6057 26.4406C59.4751 26.0789 59.2892 25.7674 59.0481 25.5062C58.8069 25.2349 58.5256 25.0239 58.2041 24.8732C57.8926 24.7225 57.5561 24.6472 57.1944 24.6472C56.8327 24.6472 56.4911 24.7275 56.1695 24.8883C55.8581 25.039 55.5818 25.25 55.3406 25.5213C55.1096 25.7825 54.9237 26.094 54.783 26.4557C54.6524 26.8073 54.5871 27.1891 54.5871 27.601ZM63.0696 31.5044V24.6773H61.9996V23.6374H63.0696V23.4716C63.0696 22.4971 63.2856 21.7335 63.7177 21.1809C64.1497 20.6283 64.7375 20.352 65.481 20.352C65.7924 20.352 66.0989 20.3972 66.4003 20.4876C66.7017 20.568 66.973 20.6835 67.2141 20.8342L66.8826 21.8138C66.7419 21.7134 66.5661 21.633 66.3551 21.5727C66.1541 21.5124 65.9532 21.4823 65.7522 21.4823C65.3102 21.4823 64.9736 21.6481 64.7425 21.9796C64.5114 22.3112 64.3959 22.7934 64.3959 23.4264V23.6374H66.4907V24.6773H64.3959V31.5044H63.0696ZM67.8135 31.5044V23.6374H69.1398V31.5044H67.8135ZM67.8135 22.1605V20.5027H69.1398V22.1605H67.8135ZM71.3622 20.5027H72.6885V31.5044H71.3622V20.5027ZM78.3888 31.6551C77.796 31.6551 77.2534 31.5496 76.7611 31.3386C76.2688 31.1176 75.8468 30.8212 75.4952 30.4494C75.1435 30.0777 74.8672 29.6457 74.6663 29.1533C74.4754 28.651 74.3799 28.1235 74.3799 27.5709C74.3799 27.0183 74.4754 26.4959 74.6663 26.0035C74.8672 25.5112 75.1435 25.0792 75.4952 24.7074C75.8569 24.3357 76.2839 24.0443 76.7762 23.8333C77.2685 23.6123 77.8111 23.5018 78.4039 23.5018C78.9966 23.5018 79.5342 23.6123 80.0164 23.8333C80.5087 24.0544 80.9257 24.3508 81.2673 24.7225C81.6089 25.0842 81.8701 25.5112 82.051 26.0035C82.2419 26.4858 82.3373 26.9882 82.3373 27.5106C82.3373 27.6211 82.3323 27.7216 82.3223 27.812C82.3223 27.9025 82.3172 27.9728 82.3072 28.023H75.7815C75.8117 28.4149 75.9021 28.7715 76.0528 29.0931C76.2035 29.4146 76.3994 29.6909 76.6406 29.922C76.8817 30.153 77.153 30.3339 77.4544 30.4645C77.7659 30.5951 78.0924 30.6604 78.434 30.6604C78.6751 30.6604 78.9112 30.6303 79.1423 30.57C79.3734 30.4997 79.5894 30.4092 79.7904 30.2987C79.9913 30.1882 80.1671 30.0526 80.3179 29.8918C80.4786 29.7311 80.5992 29.5502 80.6796 29.3493L81.8249 29.6658C81.6943 29.9571 81.5135 30.2234 81.2824 30.4645C81.0613 30.7056 80.8001 30.9166 80.4987 31.0975C80.2073 31.2683 79.8808 31.4039 79.5191 31.5044C79.1574 31.6049 78.7806 31.6551 78.3888 31.6551ZM81.0865 27.0434C81.0563 26.6717 80.9609 26.3301 80.8001 26.0186C80.6494 25.6971 80.4535 25.4258 80.2124 25.2048C79.9813 24.9837 79.71 24.8129 79.3985 24.6924C79.0871 24.5618 78.7555 24.4965 78.4039 24.4965C78.0522 24.4965 77.7206 24.5618 77.4092 24.6924C77.0977 24.8129 76.8214 24.9888 76.5803 25.2199C76.3492 25.4409 76.1583 25.7071 76.0076 26.0186C75.8669 26.3301 75.7815 26.6717 75.7514 27.0434H81.0865ZM87.8344 31.5044V20.8041H92.2954C92.7676 20.8041 93.1997 20.9046 93.5915 21.1055C93.9934 21.2964 94.335 21.5576 94.6163 21.8892C94.9077 22.2107 95.1337 22.5774 95.2945 22.9894C95.4553 23.3913 95.5356 23.8032 95.5356 24.2252C95.5356 24.6673 95.4603 25.0943 95.3096 25.5062C95.1589 25.9181 94.9428 26.2849 94.6615 26.6064C94.3902 26.9279 94.0587 27.1841 93.6668 27.375C93.2851 27.5659 92.858 27.6613 92.3858 27.6613H89.1908V31.5044H87.8344ZM89.1908 26.4557H92.3105C92.5918 26.4557 92.843 26.4004 93.064 26.2899C93.2951 26.1693 93.491 26.0086 93.6518 25.8076C93.8125 25.5966 93.9381 25.3555 94.0285 25.0842C94.119 24.8129 94.1642 24.5266 94.1642 24.2252C94.1642 23.9137 94.1089 23.6223 93.9984 23.3511C93.8979 23.0798 93.7573 22.8437 93.5764 22.6427C93.4056 22.4418 93.1997 22.2861 92.9585 22.1755C92.7274 22.065 92.4813 22.0098 92.22 22.0098H89.1908V26.4557ZM101.868 31.5044V30.3138C101.527 30.776 101.095 31.1176 100.572 31.3386C100.06 31.5496 99.5172 31.6551 98.9445 31.6551C98.5727 31.6551 98.2261 31.5948 97.9046 31.4743C97.5831 31.3436 97.3017 31.1678 97.0606 30.9468C96.8295 30.7257 96.6437 30.4695 96.503 30.1782C96.3724 29.8767 96.3071 29.5502 96.3071 29.1986C96.3071 28.7866 96.4025 28.4299 96.5934 28.1285C96.7843 27.8171 97.0355 27.5609 97.347 27.3599C97.6685 27.159 98.0251 27.0133 98.417 26.9229C98.8088 26.8224 99.2107 26.7722 99.6227 26.7722C100.135 26.7722 100.577 26.8174 100.949 26.9078C101.331 26.9882 101.622 27.0736 101.823 27.164V26.531C101.823 25.898 101.642 25.3957 101.28 25.0239C100.919 24.6522 100.406 24.4663 99.7432 24.4663C98.9495 24.4663 98.1156 24.7728 97.2415 25.3856L96.8044 24.5266C97.2565 24.2252 97.7338 23.979 98.2361 23.7881C98.7485 23.5972 99.2911 23.5018 99.8638 23.5018C100.396 23.5018 100.863 23.5771 101.265 23.7278C101.677 23.8785 102.024 24.0946 102.305 24.3759C102.587 24.6472 102.798 24.9787 102.938 25.3706C103.079 25.7624 103.149 26.1944 103.149 26.6667V31.5044H101.868ZM101.537 29.6959C101.728 29.4849 101.823 29.289 101.823 29.1081V27.9477C101.501 27.8171 101.165 27.7216 100.813 27.6613C100.472 27.601 100.14 27.5709 99.8186 27.5709C99.5573 27.5709 99.2911 27.601 99.0198 27.6613C98.7586 27.7116 98.5175 27.802 98.2964 27.9326C98.0854 28.0532 97.9096 28.2089 97.7689 28.3998C97.6383 28.5907 97.573 28.8218 97.573 29.0931C97.573 29.3241 97.6182 29.5402 97.7087 29.7411C97.7991 29.932 97.9247 30.0978 98.0854 30.2384C98.2462 30.3691 98.427 30.4746 98.628 30.5549C98.839 30.6253 99.06 30.6604 99.2911 30.6604C99.492 30.6604 99.698 30.6403 99.909 30.6001C100.13 30.56 100.341 30.5047 100.542 30.4344C100.743 30.354 100.929 30.2535 101.1 30.1329C101.27 30.0124 101.416 29.8667 101.537 29.6959ZM108.331 31.5948C107.799 31.5948 107.306 31.4843 106.854 31.2633C106.412 31.0422 106.03 30.7458 105.709 30.3741C105.387 30.0023 105.136 29.5753 104.955 29.0931C104.784 28.6108 104.699 28.1084 104.699 27.586C104.699 27.0334 104.784 26.5109 104.955 26.0186C105.136 25.5162 105.387 25.0792 105.709 24.7074C106.03 24.3357 106.412 24.0443 106.854 23.8333C107.306 23.6123 107.804 23.5018 108.346 23.5018C109.009 23.5018 109.582 23.6625 110.064 23.984C110.546 24.3056 110.953 24.7125 111.285 25.2048V23.6374H112.46V31.414C112.46 31.9766 112.355 32.4689 112.144 32.8909C111.933 33.3229 111.647 33.6846 111.285 33.976C110.923 34.2674 110.496 34.4884 110.004 34.6391C109.522 34.7898 109.004 34.8652 108.452 34.8652C107.557 34.8652 106.829 34.7095 106.266 34.398C105.714 34.0966 105.252 33.6746 104.88 33.132L105.648 32.4539C105.96 32.916 106.362 33.2627 106.854 33.4937C107.346 33.7248 107.879 33.8404 108.452 33.8404C108.813 33.8404 109.155 33.7901 109.476 33.6897C109.808 33.5892 110.094 33.4385 110.335 33.2375C110.577 33.0366 110.767 32.7854 110.908 32.484C111.059 32.1826 111.134 31.8259 111.134 31.414V30.0275C110.983 30.2686 110.803 30.4896 110.592 30.6906C110.391 30.8815 110.17 31.0472 109.929 31.1879C109.687 31.3185 109.431 31.419 109.16 31.4893C108.889 31.5597 108.612 31.5948 108.331 31.5948ZM108.738 30.5097C109.019 30.5097 109.291 30.4595 109.552 30.359C109.813 30.2585 110.054 30.1279 110.275 29.9672C110.496 29.7964 110.682 29.6055 110.833 29.3945C110.983 29.1835 111.084 28.9675 111.134 28.7464V26.4858C111.024 26.2145 110.878 25.9684 110.697 25.7473C110.526 25.5263 110.33 25.3354 110.109 25.1746C109.888 25.0038 109.647 24.8732 109.386 24.7828C109.135 24.6924 108.879 24.6472 108.617 24.6472C108.215 24.6472 107.854 24.7326 107.532 24.9034C107.221 25.0742 106.955 25.3002 106.733 25.5816C106.512 25.8629 106.342 26.1844 106.221 26.5461C106.101 26.8977 106.04 27.2544 106.04 27.6161C106.04 28.008 106.111 28.3797 106.251 28.7314C106.392 29.083 106.583 29.3895 106.824 29.6507C107.065 29.9119 107.346 30.1229 107.668 30.2837C107.999 30.4344 108.356 30.5097 108.738 30.5097ZM118.082 31.6551C117.489 31.6551 116.947 31.5496 116.455 31.3386C115.962 31.1176 115.54 30.8212 115.189 30.4494C114.837 30.0777 114.561 29.6457 114.36 29.1533C114.169 28.651 114.073 28.1235 114.073 27.5709C114.073 27.0183 114.169 26.4959 114.36 26.0035C114.561 25.5112 114.837 25.0792 115.189 24.7074C115.55 24.3357 115.977 24.0443 116.47 23.8333C116.962 23.6123 117.504 23.5018 118.097 23.5018C118.69 23.5018 119.228 23.6123 119.71 23.8333C120.202 24.0544 120.619 24.3508 120.961 24.7225C121.302 25.0842 121.564 25.5112 121.744 26.0035C121.935 26.4858 122.031 26.9882 122.031 27.5106C122.031 27.6211 122.026 27.7216 122.016 27.812C122.016 27.9025 122.011 27.9728 122.001 28.023H115.475C115.505 28.4149 115.596 28.7715 115.746 29.0931C115.897 29.4146 116.093 29.6909 116.334 29.922C116.575 30.153 116.846 30.3339 117.148 30.4645C117.459 30.5951 117.786 30.6604 118.127 30.6604C118.369 30.6604 118.605 30.6303 118.836 30.57C119.067 30.4997 119.283 30.4092 119.484 30.2987C119.685 30.1882 119.861 30.0526 120.011 29.8918C120.172 29.7311 120.293 29.5502 120.373 29.3493L121.518 29.6658C121.388 29.9571 121.207 30.2234 120.976 30.4645C120.755 30.7056 120.494 30.9166 120.192 31.0975C119.901 31.2683 119.574 31.4039 119.213 31.5044C118.851 31.6049 118.474 31.6551 118.082 31.6551ZM120.78 27.0434C120.75 26.6717 120.654 26.3301 120.494 26.0186C120.343 25.6971 120.147 25.4258 119.906 25.2048C119.675 24.9837 119.403 24.8129 119.092 24.6924C118.78 24.5618 118.449 24.4965 118.097 24.4965C117.746 24.4965 117.414 24.5618 117.103 24.6924C116.791 24.8129 116.515 24.9888 116.274 25.2199C116.043 25.4409 115.852 25.7071 115.701 26.0186C115.56 26.3301 115.475 26.6717 115.445 27.0434H120.78Z" fill="#4859B2"/> +<mask id="mask0_239_6997" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="1" width="33" height="33"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0537 1.9751H3.27627C1.46684 1.9751 0 3.44193 0 5.25137V17.828V30.4047C0 32.2141 1.46684 33.681 3.27627 33.681H16.0537H28.8312C30.6406 33.681 32.1074 32.2141 32.1074 30.4047V17.828V5.25137C32.1074 3.44193 30.6406 1.9751 28.8312 1.9751H16.0537ZM16.0537 1.9751C24.9199 1.9751 32.1074 9.0727 32.1074 17.828C32.1074 26.5834 24.9199 33.681 16.0537 33.681C7.18749 33.681 0 26.5834 0 17.828C0 9.0727 7.18749 1.9751 16.0537 1.9751Z" fill="#D9D9D9"/> +<path d="M32.1074 17.828C32.1074 26.5834 24.9199 33.681 16.0537 33.681C7.18749 33.681 0 26.5834 0 17.828C0 9.0727 7.18749 1.9751 16.0537 1.9751C24.9199 1.9751 32.1074 9.0727 32.1074 17.828ZM2.29328 17.828C2.29328 25.3327 8.45404 31.4164 16.0537 31.4164C23.6534 31.4164 29.8142 25.3327 29.8142 17.828C29.8142 10.3234 23.6534 4.2397 16.0537 4.2397C8.45404 4.2397 2.29328 10.3234 2.29328 17.828Z" fill="#D9D9D9"/> +<path d="M6.22656 14.916H12.0146C12.0146 14.916 13.4344 16.4258 16.1646 16.5337C18.6764 16.4258 20.0961 14.916 20.0961 14.916H25.8842V27.4258C18.3422 33.8035 13.2159 33.5729 6.22656 27.4258V14.916Z" fill="#D9D9D9" stroke="black" stroke-width="0.218418"/> +<ellipse cx="16.055" cy="10.7104" rx="4.9144" ry="4.85294" fill="#D9D9D9"/> +</mask> +<g mask="url(#mask0_239_6997)"> +<rect y="1.9751" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect x="6.70312" y="1.9751" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect x="13.3242" y="1.9751" width="5.46045" height="3.23529" fill="#EF883B"/> +<rect x="20.1094" y="1.9751" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect x="26.8164" y="1.9751" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect y="8.59521" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect x="6.76953" y="8.66162" width="3.93152" height="5.17647" fill="#EF883B"/> +<rect x="12.2305" y="7.58301" width="7.64463" height="7.54902" rx="3.77451" fill="#4859B2"/> +<rect x="21.625" y="8.66162" width="3.7131" height="5.17647" fill="#EF883B"/> +<rect x="26.8164" y="8.59473" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect y="15.2148" width="5.29244" height="5.22624" fill="#EF883B"/> +<path d="M6.70312 18.4911C6.70312 16.6817 8.16996 15.2148 9.97939 15.2148H11.9956V20.4411H6.70312V18.4911Z" fill="#4859B2"/> +<rect x="13.3242" y="15.5635" width="5.46045" height="4.96078" fill="#4859B2"/> +<path d="M20.1094 15.2148H22.1255C23.935 15.2148 25.4018 16.6817 25.4018 18.4911V20.4411H20.1094V15.2148Z" fill="#4859B2"/> +<rect x="26.8164" y="15.2148" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect y="21.835" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect x="6.70312" y="21.835" width="5.29244" height="5.22624" fill="#4859B2"/> +<rect x="13.4062" y="21.835" width="5.29244" height="5.22624" fill="#4859B2"/> +<rect x="20.1094" y="21.835" width="5.29244" height="5.22624" fill="#4859B2"/> +<rect x="26.8164" y="21.835" width="5.29244" height="5.22624" fill="#EF883B"/> +<rect y="28.4546" width="5.29244" height="5.22624" fill="#4859B2"/> +<rect x="6.70312" y="28.4546" width="5.29244" height="5.22624" fill="#4859B2"/> +<rect x="13.4062" y="28.4546" width="5.29244" height="5.22624" fill="#4859B2"/> +<rect x="20.1094" y="28.4546" width="5.29244" height="5.22624" fill="#4859B2"/> +<rect x="26.8164" y="28.4546" width="5.29244" height="5.22624" fill="#4859B2"/> +</g> +</svg> diff --git a/www/assets/css/src/icons/i.svg b/www/assets/css/src/icons/i.svg new file mode 100644 index 0000000000000000000000000000000000000000..464804df90d6ff1d8f31ec37c7c96279b646ad81 --- /dev/null +++ b/www/assets/css/src/icons/i.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.416 17.542H12.7285V10.8337H11.416V17.542ZM11.9993 8.96699C12.2132 8.96699 12.398 8.89407 12.5535 8.74824C12.7091 8.60241 12.7868 8.41283 12.7868 8.17949C12.7868 7.94616 12.7139 7.75658 12.5681 7.61074C12.4223 7.46491 12.2327 7.39199 11.9993 7.39199C11.766 7.39199 11.5764 7.46491 11.4306 7.61074C11.2848 7.75658 11.2118 7.94616 11.2118 8.17949C11.2118 8.41283 11.2848 8.60241 11.4306 8.74824C11.5764 8.89407 11.766 8.96699 11.9993 8.96699ZM11.9993 23.0837C10.4632 23.0837 9.02435 22.792 7.68268 22.2087C6.34102 21.6253 5.16949 20.833 4.1681 19.8316C3.16671 18.8302 2.37435 17.6587 1.79102 16.317C1.20768 14.9753 0.916016 13.5364 0.916016 12.0003C0.916016 10.4642 1.20768 9.02046 1.79102 7.66908C2.37435 6.31769 3.16671 5.14616 4.1681 4.15449C5.16949 3.16283 6.34102 2.37533 7.68268 1.79199C9.02435 1.20866 10.4632 0.916992 11.9993 0.916992C13.5355 0.916992 14.9792 1.20866 16.3306 1.79199C17.682 2.37533 18.8535 3.16283 19.8452 4.15449C20.8368 5.14616 21.6243 6.31769 22.2077 7.66908C22.791 9.02046 23.0827 10.4642 23.0827 12.0003C23.0827 13.5364 22.791 14.9753 22.2077 16.317C21.6243 17.6587 20.8368 18.8302 19.8452 19.8316C18.8535 20.833 17.682 21.6253 16.3306 22.2087C14.9792 22.792 13.5355 23.0837 11.9993 23.0837ZM12.0285 21.7712C14.7313 21.7712 17.0306 20.8184 18.9264 18.9128C20.8223 17.0073 21.7702 14.6934 21.7702 11.9712C21.7702 9.26838 20.8223 6.96908 18.9264 5.07324C17.0306 3.17741 14.7216 2.22949 11.9993 2.22949C9.29657 2.22949 6.9924 3.17741 5.08685 5.07324C3.18129 6.96908 2.22852 9.2781 2.22852 12.0003C2.22852 14.7031 3.18129 17.0073 5.08685 18.9128C6.9924 20.8184 9.30629 21.7712 12.0285 21.7712Z" fill="#4859B2"/> +</svg> diff --git a/www/assets/css/src/icons/logout.svg b/www/assets/css/src/icons/logout.svg new file mode 100644 index 0000000000000000000000000000000000000000..b70c4bb938038d57afa0339eed4571170c64cc76 --- /dev/null +++ b/www/assets/css/src/icons/logout.svg @@ -0,0 +1,3 @@ +<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M1.89089 20.4997C1.42422 20.4997 1.02561 20.3344 0.695052 20.0038C0.364497 19.6733 0.199219 19.2747 0.199219 18.808V1.77467C0.199219 1.30801 0.364497 0.909397 0.695052 0.578841C1.02561 0.248286 1.42422 0.0830078 1.89089 0.0830078H9.94089V1.39551H1.89089C1.79366 1.39551 1.70616 1.4344 1.62839 1.51217C1.55061 1.58995 1.51172 1.67745 1.51172 1.77467V18.808C1.51172 18.9052 1.55061 18.9927 1.62839 19.0705C1.70616 19.1483 1.79366 19.1872 1.89089 19.1872H9.94089V20.4997H1.89089ZM15.3659 14.7247L14.4034 13.7913L17.2617 10.9622H6.96588V9.62051H17.2326L14.3742 6.79134L15.3367 5.82884L19.7992 10.3205L15.3659 14.7247Z" fill="black"/> +</svg> diff --git a/www/assets/css/src/icons/prof-page.svg b/www/assets/css/src/icons/prof-page.svg new file mode 100644 index 0000000000000000000000000000000000000000..c353f4e25b3415dd578582b9c2fb82e95884b4b1 --- /dev/null +++ b/www/assets/css/src/icons/prof-page.svg @@ -0,0 +1,3 @@ +<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M5.39844 21.6705C6.54566 20.6011 7.84358 19.7406 9.29219 19.0893C10.7408 18.4379 12.3109 18.1122 14.0026 18.1122C15.6943 18.1122 17.2644 18.4379 18.713 19.0893C20.1616 19.7406 21.4595 20.6011 22.6068 21.6705V5.77467C22.6068 5.67745 22.5679 5.58995 22.4901 5.51217C22.4123 5.4344 22.3248 5.39551 22.2276 5.39551H5.7776C5.68038 5.39551 5.59288 5.4344 5.5151 5.51217C5.43733 5.58995 5.39844 5.67745 5.39844 5.77467V21.6705ZM14.0318 15.633C15.0818 15.633 15.9714 15.2684 16.7005 14.5393C17.4297 13.8101 17.7943 12.9205 17.7943 11.8705C17.7943 10.8205 17.4297 9.93092 16.7005 9.20176C15.9714 8.47259 15.0818 8.10801 14.0318 8.10801C12.9818 8.10801 12.097 8.47259 11.3776 9.20176C10.6582 9.93092 10.2984 10.8205 10.2984 11.8705C10.2984 12.9205 10.6582 13.8101 11.3776 14.5393C12.097 15.2684 12.9818 15.633 14.0318 15.633ZM5.7776 23.9163C5.31094 23.9163 4.91233 23.7511 4.58177 23.4205C4.25122 23.09 4.08594 22.6913 4.08594 22.2247V5.77467C4.08594 5.30801 4.25122 4.9094 4.58177 4.57884C4.91233 4.24829 5.31094 4.08301 5.7776 4.08301H22.2276C22.6943 4.08301 23.0929 4.24829 23.4234 4.57884C23.754 4.9094 23.9193 5.30801 23.9193 5.77467V22.2247C23.9193 22.6913 23.754 23.09 23.4234 23.4205C23.0929 23.7511 22.6943 23.9163 22.2276 23.9163H5.7776ZM6.5651 22.6038H21.4401V22.3705C20.3512 21.3983 19.1797 20.6643 17.9255 20.1684C16.6714 19.6726 15.3637 19.4247 14.0026 19.4247C12.6609 19.4247 11.3582 19.6677 10.0943 20.1538C8.83038 20.64 7.65399 21.3691 6.5651 22.3413V22.6038ZM14.0609 14.2913C13.3804 14.2913 12.8019 14.058 12.3255 13.5913C11.8491 13.1247 11.6109 12.5511 11.6109 11.8705C11.6109 11.19 11.8491 10.6163 12.3255 10.1497C12.8019 9.68301 13.3707 9.44968 14.0318 9.44968C14.7123 9.44968 15.2908 9.68787 15.7672 10.1643C16.2436 10.6406 16.4818 11.2094 16.4818 11.8705C16.4818 12.5511 16.2436 13.1247 15.7672 13.5913C15.2908 14.058 14.722 14.2913 14.0609 14.2913Z" fill="black"/> +</svg> diff --git a/www/assets/css/src/icons/x.svg b/www/assets/css/src/icons/x.svg new file mode 100644 index 0000000000000000000000000000000000000000..20845d7ede94cc36f113f7a92d9e82d922a0cbe8 --- /dev/null +++ b/www/assets/css/src/icons/x.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M1.32005 15.6122L0.386719 14.6789L7.06589 7.99971L0.386719 1.32054L1.32005 0.387207L7.99922 7.06637L14.6784 0.387207L15.6117 1.32054L8.93255 7.99971L15.6117 14.6789L14.6784 15.6122L7.99922 8.93304L1.32005 15.6122Z" fill="#4859B2"/> +</svg>