commit 4df16439bf3b65ecfd139f8fb089a6d63c7fe7e2 Author: Felipe Cotti Date: Fri Oct 4 19:27:04 2024 -0300 Initial commit. Here we go. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb1de54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +[Oo]bj/ +[Bb]in/ +.nuget/ +_ReSharper.* +packages/ +artifacts/ +*.user +*.suo +*.userprefs +*DS_Store +*.sln.ide +build/docker-compose.yml +build/mongodb/mongodata/ +src/Guestbooky/.vs/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3972c30 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, 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 +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If 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 convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "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 PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM 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 PROGRAM (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 PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) 2024 + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc90f3a --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +

+ + Guestbooky Project logo +

+ +

Guestbooky

+ +
+ +[![Status](https://img.shields.io/badge/status-active-success.svg)]() +[![GitHub Issues](https://img.shields.io/github/issues/cotti/MessengerPlusSoundBankExtractor.svg)](https://github.com/cotti/MessengerPlusSoundBankExtractor/issues) +[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/cotti/MessengerPlusSoundBankExtractor.svg)](https://github.com/cotti/MessengerPlusSoundBankExtractor/pulls) +[![License](https://img.shields.io/badge/license-AGPLv3-003300.svg)](/LICENSE) + +
+ +--- + + +

A simple yet somehow overdesigned guestbook system featuring a simple control panel (which is a WIP so you'll have to make do with a db manager)

+ +

This is phase I of the personal backscratchers project.

+ +## 📝 Table of Contents + +- [About](#about) +- [Getting Started](#getting_started) +- [Deployment](#deployment) +- [Usage](#usage) +- [Built Using](#built_using) +- [Authors](#authors) + +## 🧐 About + +I really need to get my hands dirty from time to time, so I figured I'd make a guestbook for my marriage hotsite. And make everyone else see this code. + +It includes many concepts that are very reasonable to tinker with as learning material, in a bite-sized project complexity that allows me to talk about it without losing the breadcrumb trail. + +## 🏁 Getting Started + +These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See [deployment](#deployment) for notes on how to deploy the project on a live system. + +### Prerequisites + +- .NET 8.0 +- A running instance of MongoDB +- A Cloudflare turnstile secret key for the captcha +- Not forget to set up environment variables + +You will be able to see in `build/docker-compose.public.yml` that the application makes heavy usage of them. +``` + - ASPNETCORE_ENVIRONMENT=Production + - CORS_ORIGINS=https://guestbook.example.com,http://localhost:5008,http://localhost:8080 + - ACCESS_USERNAME=user + - ACCESS_PASSWORD=pass + - ACCESS_TOKENKEY=pleaseinsertafairlylargetokenkeyherewillyou + - ACCESS_ISSUER=https://guestbook.example.com/api + - ACCESS_AUDIENCE=https://guestbook.example.com + - CLOUDFLARE_SECRET=0x000000000000000000000000000000000 + - MONGODB_CONNECTIONSTRING=mongodb://mongouser:mongopass@mongo:27017/Guestbooky + - MONGODB_DATABASENAME=Guestbooky +``` + +You will need to set them up either by hand or by using your IDE's capabilities. On Visual Studio, that can be done via the Debug Properties of Guestbooky.API. + +**CORS_ORIGINS**, **ACCESS_\*** -> variables related to JWT issuing and checking. In order to use the GET and DELETE endpoints for the messages, you need to use a bearer token. + +**CLOUDFLARE_SECRET** -> The turnstile secret, used in the server portion of the captcha check. + +**MONGODB_\*** -> Related to the connection to MongoDB. Yeah. + +## 🎈 Usage + +For local usage of the backend, you can use `docker-compose.local.yml` and edit the fields you need. + +## 🚀 Deployment + +Use `docker-compose.public.yml` as a basis. it should create the image for you and start running. + +## ⛏️ Built Using + +- [MongoDB](https://www.mongodb.com/) - Database +- [.NET](https://dot.net/) - Backend +- [Cloudflare Turnstile](https://www.cloudflare.com/pt-br/products/turnstile/) - Captcha + +## ✍️ Authors + +- [@cotti](https://github.com/cotti) | [cotti.com.br](https://cotti.com.br) \ No newline at end of file diff --git a/build/docker-compose.local.yml b/build/docker-compose.local.yml new file mode 100644 index 0000000..63520fd --- /dev/null +++ b/build/docker-compose.local.yml @@ -0,0 +1,33 @@ +services: + + mongo: + image: mongo + container_name: mongo + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: mongo + volumes: + - ./mongodb/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro + - ./mongodb/mongod.conf:/etc/mongod.conf:ro + - mongodata:/data/db + command: ["mongod", "--config", "/etc/mongod.conf"] + ports: + - "27017:27017" + + mongo-express: + image: mongo-express + container_name: mongo-express + restart: always + ports: + - 8082:8081 + environment: + ME_CONFIG_MONGODB_ADMINUSERNAME: root + ME_CONFIG_MONGODB_ADMINPASSWORD: mongo + ME_CONFIG_MONGODB_URL: mongodb://root:mongo@mongo:27017/ + ME_CONFIG_BASICAUTH: false + depends_on: + - mongo + +volumes: + mongodata: \ No newline at end of file diff --git a/build/docker-compose.public.yml b/build/docker-compose.public.yml new file mode 100644 index 0000000..0c75725 --- /dev/null +++ b/build/docker-compose.public.yml @@ -0,0 +1,40 @@ +services: + + guestbooky-be: + build: + context: ../ + dockerfile: build/guestbooky-be/Dockerfile + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - CORS_ORIGINS=https://guestbooky.example.com + - ACCESS_USERNAME=user + - ACCESS_PASSWORD=pass + - ACCESS_TOKENKEY=youbetterbesureyouareusingatokenkey + - ACCESS_ISSUER=https://guestbooky.example.com/api + - ACCESS_AUDIENCE=https://guestbooky.example.com + - CLOUDFLARE_SECRET=0x000000000000000000000000000000000 + - MONGODB_CONNECTIONSTRING=mongodb://mongouser:mongopass@mongo:27017/Guestbooky + - MONGODB_DATABASENAME=Guestbooky + - LOG_LEVEL=Debug + depends_on: + - mongo + + mongo: + image: mongo + container_name: mongo + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: mongo + volumes: + - ./mongodb/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro + - ./mongodb/mongod.conf:/etc/mongod.conf:ro + - mongodata:/data/db + command: ["mongod", "--config", "/etc/mongod.conf"] + ports: + - "27017:27017" + +volumes: + mongodata: \ No newline at end of file diff --git a/build/guestbooky-be/Dockerfile b/build/guestbooky-be/Dockerfile new file mode 100644 index 0000000..bcfe089 --- /dev/null +++ b/build/guestbooky-be/Dockerfile @@ -0,0 +1,18 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +WORKDIR /app +COPY ../../src/Guestbooky/ . +COPY ../../tests/ . +RUN dotnet restore ./Guestbooky.API/ + +RUN dotnet build ./Guestbooky.API/Guestbooky.API.csproj -c Release -o /app/build + +RUN dotnet publish ./Guestbooky.API/Guestbooky.API.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app + +COPY --from=build /app/publish . +EXPOSE 5008 + +ENTRYPOINT ["dotnet", "Guestbooky.API.dll"] diff --git a/build/mongodb/mongo-init.js b/build/mongodb/mongo-init.js new file mode 100644 index 0000000..276b0a1 --- /dev/null +++ b/build/mongodb/mongo-init.js @@ -0,0 +1,18 @@ +db = db.getSiblingDB('Guestbooky'); + +db.createUser( + { + user: "guestbookyuser", + pwd: "guestbookypassword", + roles: [ + { + role: "readWrite", + db: "Guestbooky" + } + ] + } +); + +db.createCollection("GuestbookMessages"); + +db.GuestbookMessages.createIndex({ "Timestamp": 1 }); \ No newline at end of file diff --git a/build/mongodb/mongod.conf b/build/mongodb/mongod.conf new file mode 100644 index 0000000..61f136c --- /dev/null +++ b/build/mongodb/mongod.conf @@ -0,0 +1,2 @@ +net: + bindIp: 0.0.0.0 \ No newline at end of file diff --git a/docs/guestbooky.png b/docs/guestbooky.png new file mode 100644 index 0000000..9374e32 Binary files /dev/null and b/docs/guestbooky.png differ diff --git a/src/Guestbooky/Guestbooky.API/Controllers/AuthController.cs b/src/Guestbooky/Guestbooky.API/Controllers/AuthController.cs new file mode 100644 index 0000000..88cbd0b --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/Controllers/AuthController.cs @@ -0,0 +1,83 @@ +using Guestbooky.API.DTOs.Auth; +using Guestbooky.Application.UseCases.AuthenticateUser; +using Guestbooky.Application.UseCases.RefreshToken; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace Guestbooky.API.Controllers; + +[ApiController] +[Route("[controller]")] +public class AuthController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public AuthController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + [ProducesResponseType(typeof(LoginResponseDto), 200)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails),StatusCodes.Status500InternalServerError)] + [HttpPost("login")] + public async Task Login([FromBody] LoginRequestDto request, CancellationToken token) + { + try + { + _logger.LogInformation("Login request endpoint reached. Sending command."); + var command = new AuthenticateUserCommand(request.Username, request.Password); + var result = await _mediator.Send(command, token); + + if(result.IsAuthenticated) + { + _logger.LogInformation("Authentication successful. Returning LoginResponse."); + return Ok(new LoginResponseDto(result.Token, result.RefreshToken)); + } + else + { + _logger.LogInformation("Authentication processed, but credentials did not match."); + return Unauthorized(); + } + } + catch (Exception e) + { + _logger.LogError(e, "An exception occurred upon trying to login. Returning server error."); + return Problem($"An error occurred on the server: {e.Message}", statusCode: StatusCodes.Status500InternalServerError); + } + } + + [ProducesResponseType(typeof(RefreshTokenResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + [HttpPost("refreshtoken")] + public async Task RefreshToken([FromBody] RefreshTokenRequestDto request, CancellationToken token) + { + try + { + _logger.LogInformation("Refresh token endpoint reached. Sending command."); + var command = new RefreshTokenCommand(request.RefreshToken); + + var result = await _mediator.Send(command, token); + if (result.Success) + { + _logger.LogInformation("Refresh token request successful."); + return Ok(new RefreshTokenResponseDto(result.Token, result.RefreshToken)); + } + else + { + _logger.LogInformation($"Refresh token request failed. Reason: {result.ErrorMessage}"); + return Unauthorized(new ProblemDetails() { Detail = result.ErrorMessage, Title = "Refresh token failed" }); + } + } + catch (Exception e) + { + _logger.LogError(e, "An exception occurred upon trying to refresh the token. Returning server error."); + return Problem($"An error occurred on the server: {e.Message}", statusCode: StatusCodes.Status500InternalServerError); + } + } +} diff --git a/src/Guestbooky/Guestbooky.API/Controllers/MessageController.cs b/src/Guestbooky/Guestbooky.API/Controllers/MessageController.cs new file mode 100644 index 0000000..832d390 --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/Controllers/MessageController.cs @@ -0,0 +1,117 @@ +using Guestbooky.API.DTOs.Messages; +using Guestbooky.Application.UseCases.CountGuestbookMessages; +using Guestbooky.Application.UseCases.DeleteGuestbookMessage; +using Guestbooky.Application.UseCases.InsertGuestMessage; +using Guestbooky.Application.UseCases.ListGuestbookMessages; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Guestbooky.API.Controllers; + +[ApiController] +[Route("[controller]")] +public class MessageController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public MessageController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task Insert([FromBody] InsertMessageRequestDto message, CancellationToken token) + { + try + { + _logger.LogInformation("Add guestbook message endpoint reached. Sending command."); + var result = await _mediator.Send(new InsertGuestbookMessageCommand(message.Author, message.Message, message.CaptchaResponse), token); + + if (result.Success) return Created(); + else + { + _logger.LogError($"It wasn't possible to add the message. Reason: {result.ErrorMessage}"); + return Problem(result.ErrorMessage, statusCode: StatusCodes.Status403Forbidden); + } + } + catch (Exception e) + { + _logger.LogError(e, "An exception occurred upon trying to insert new guestbook entry. Returning server error."); + return Problem($"An error occurred on the server: {e.Message}", statusCode: StatusCodes.Status500InternalServerError); + } + } + + [HttpGet] + [Authorize] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status416RequestedRangeNotSatisfiable)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task Get([FromHeader(Name = "Range")] GetMessagesRequestDto request, CancellationToken token) + { + try + { + _logger.LogInformation("List guestbook messages endpoint reached. Sending command."); + var query = new ListGuestbookMessagesQuery(request.Offset); + var queryResult = await _mediator.Send(query, token); + + var responseResult = queryResult.Messages.Select(message => new GetMessagesResponseDto(message.Id, message.Author, message.Message, message.Timestamp)); + + var totalMessages = await GetMessagesTotalAmount(token); + + Response.Headers.AcceptRanges = "messages"; + Response.Headers.ContentRange = $"messages {request.Offset}-{request.Offset + responseResult.Count() - 1}/{totalMessages}"; + Response.StatusCode = StatusCodes.Status206PartialContent; + + return new ObjectResult(responseResult); + } + catch (Exception e) + { + _logger.LogError(e, "An exception occurred upon trying to acquire the guestbook entries. Returning server error."); + return Problem($"An error occurred on the server: {e.Message}", statusCode: StatusCodes.Status500InternalServerError); + } + } + + [HttpDelete] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task Delete([FromBody] DeleteMessageRequestDto message, CancellationToken token) + { + try + { + _logger.LogInformation("Delete guestbook message endpoint reached. Sending command."); + var result = await _mediator.Send(new DeleteGuestbookMessageCommand(message.Id), token); + + if (result.Success) + { + _logger.LogInformation("Deletion successful. Returning Ok."); + return Ok(); + } + else + { + _logger.LogError($"An issue occurred upon trying to delete the message. Returning server error."); + return Problem($"Could not delete the guestbook entry.", statusCode: StatusCodes.Status500InternalServerError); + } + } + catch (Exception e) + { + _logger.LogError(e, "An exception occurred upon trying to delete a guestbook entry. Returning server error."); + return Problem($"An error occurred on the server: {e.Message}", statusCode: StatusCodes.Status500InternalServerError); + } + } + + private async Task GetMessagesTotalAmount(CancellationToken cancellationToken) + { + var query = new CountGuestbookMessagesQuery(); + var queryResult = await _mediator.Send(query, cancellationToken); + return queryResult.Amount; + } +} diff --git a/src/Guestbooky/Guestbooky.API/DTOs/Auth/Login.cs b/src/Guestbooky/Guestbooky.API/DTOs/Auth/Login.cs new file mode 100644 index 0000000..8de23fa --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/DTOs/Auth/Login.cs @@ -0,0 +1,5 @@ +namespace Guestbooky.API.DTOs.Auth; + +public record LoginRequestDto(string Username, string Password); + +public record LoginResponseDto(string Token, string RefreshToken); \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.API/DTOs/Auth/RefreshToken.cs b/src/Guestbooky/Guestbooky.API/DTOs/Auth/RefreshToken.cs new file mode 100644 index 0000000..4cc95b2 --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/DTOs/Auth/RefreshToken.cs @@ -0,0 +1,5 @@ +namespace Guestbooky.API.DTOs.Auth; + +public record RefreshTokenRequestDto(string RefreshToken); + +public record RefreshTokenResponseDto(string Token, string RefreshToken); \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.API/DTOs/Messages/DeleteMessages.cs b/src/Guestbooky/Guestbooky.API/DTOs/Messages/DeleteMessages.cs new file mode 100644 index 0000000..e0ecd47 --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/DTOs/Messages/DeleteMessages.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Guestbooky.API.DTOs.Messages; + +public record DeleteMessageRequestDto +{ + [JsonPropertyName("Id")] + public required string Id { get; init; } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.API/DTOs/Messages/GetMessages.cs b/src/Guestbooky/Guestbooky.API/DTOs/Messages/GetMessages.cs new file mode 100644 index 0000000..b04a072 --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/DTOs/Messages/GetMessages.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using System.ComponentModel.DataAnnotations; +using System.Net.Http.Headers; +using System.Text.Json.Serialization; + +namespace Guestbooky.API.DTOs.Messages; + +[SwaggerSchema("asd", Format = "string", Description = "Optional Range header (e.g., 'messages=1-50')")] +[SwaggerSubType(typeof(string))] +public record GetMessagesRequestDto +{ + [FromHeader(Name = "Range")] + public required RangeHeaderValue Range { get; init; } + + public string Unit => Range?.Unit ?? string.Empty; + + public long Offset => Range?.Ranges.FirstOrDefault()?.From ?? 0; +} + +public record GetMessagesResponseDto(string Id, string Author, string Message, DateTimeOffset Timestamp); \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.API/DTOs/Messages/InsertMessage.cs b/src/Guestbooky/Guestbooky.API/DTOs/Messages/InsertMessage.cs new file mode 100644 index 0000000..f116579 --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/DTOs/Messages/InsertMessage.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Guestbooky.API.DTOs.Messages; + +public record InsertMessageRequestDto +{ + [JsonPropertyName("author")] + [Required(ErrorMessage = "The author field is required")] + [StringLength(200, ErrorMessage = "Author cannot be longer than 200 characters")] + public string Author { get; init; } + + [JsonPropertyName("message")] + [Required(AllowEmptyStrings = false, ErrorMessage = "The message field is required")] + [StringLength(4096, ErrorMessage = "Author cannot be longer than 4096 characters")] + public string Message { get; init; } + + [JsonPropertyName("captchaResponse")] + [Required(ErrorMessage = "The captcha challenge response is required")] + [StringLength(1024)] + public string CaptchaResponse { get; init; } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.API/Guestbooky.API.csproj b/src/Guestbooky/Guestbooky.API/Guestbooky.API.csproj new file mode 100644 index 0000000..33c36c9 --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/Guestbooky.API.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + AGPL-3.0-or-later + ..\..\..\LICENSE + Felipe Cotti + + + + + + + + + + + + + + + + diff --git a/src/Guestbooky/Guestbooky.API/Program.cs b/src/Guestbooky/Guestbooky.API/Program.cs new file mode 100644 index 0000000..d23ef8e --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/Program.cs @@ -0,0 +1,173 @@ +using Guestbooky.Application.DependencyInjection; +using Guestbooky.Infrastructure.DependencyInjection; +using Guestbooky.Infrastructure.Environment; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using Serilog; +using Guestbooky.API.Validations; + +namespace Guestbooky.API +{ + public static class Program + { + public static async Task Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + await builder.AddLogging(); + + if (!ValidateEnvironment(builder.Configuration)) + return; + + await builder.AddServices(); + + var app = builder.Build(); + + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + app.UseCors("local"); + + app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); + + + app.MapControllers(); + + await app.RunAsync(); + } + + private static ValueTask AddLogging(this WebApplicationBuilder builder) + { + builder.Host.UseSerilog((context, conf) => + { + conf.UseLogLevel(builder.Configuration[Constants.LOG_LEVEL]!.ToUpper()); + conf.WriteTo.Console(); + conf.ReadFrom.Configuration(context.Configuration); + conf.MinimumLevel.Debug(); + conf.Enrich.AtLevel(Serilog.Events.LogEventLevel.Debug, enricher => + { + enricher.FromLogContext(); + }); + }); + + return ValueTask.CompletedTask; + } + + /// + /// Upon checking we have all variables we need, perform the necessary injections.
+ /// -> is defined to accept requests from the specified origins
+ /// -> ASP.NET Controllers are added
+ /// -> JWT Authentication is set up
+ /// -> The Infrastructure and Application layers are added
+ /// -> If we're in development, we add OpenAPI support. + ///
+ private static ValueTask AddServices(this WebApplicationBuilder builder) + { + builder.Services.AddCors(cfg => + { + var corsOrigins = builder.Configuration[Constants.CORS_ORIGINS]?.Split(',') ?? Array.Empty(); + cfg.AddPolicy(name: "local", policy => + { + policy.WithOrigins(corsOrigins); + policy.AllowAnyHeader(); + policy.AllowAnyMethod(); + }); + }); + + builder.Services.AddControllers().ConfigureApiBehaviorOptions(options => + { + options.InvalidModelStateResponseFactory = InvalidModelStateResponseFactory.DefaultInvalidModelStateResponse; + }); + + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.RequireHttpsMetadata = false; + o.SaveToken = true; + o.TokenValidationParameters = new() + { + ValidIssuer = builder.Configuration[Constants.ACCESS_ISSUER], + ValidAudience = builder.Configuration[Constants.ACCESS_AUDIENCE], + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration[Constants.ACCESS_TOKENKEY]!)), + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + + }); + + builder.Services.AddInfrastructure(builder.Configuration); + builder.Services.AddApplication(); + + + if (builder.Environment.IsDevelopment()) + { + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(o => + { + o.EnableAnnotations(); + var jwtSecurityScheme = new Microsoft.OpenApi.Models.OpenApiSecurityScheme() + { + BearerFormat = "JWT", + Scheme = JwtBearerDefaults.AuthenticationScheme, + In = Microsoft.OpenApi.Models.ParameterLocation.Header, + Description = "Please add the bearer token", + Name = "JWT Authentication", + Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, + Reference = new() + { + Id = JwtBearerDefaults.AuthenticationScheme, + Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme + } + }; + o.AddSecurityDefinition("Bearer", jwtSecurityScheme); + o.AddSecurityRequirement(new() + { + { jwtSecurityScheme, Array.Empty() } + }); + }); + } + + return ValueTask.CompletedTask; + } + + /// + /// There's little to do if we don't have all the information we're supposed to use, so let's validate and fail early if needed. + /// + private static bool ValidateEnvironment(IConfiguration configuration) + { + if (configuration == null) return false; + + bool validConfig = true; + foreach (var constant in Constants.GetAllConstantKeys().Where(constant => string.IsNullOrWhiteSpace(configuration[constant]))) + { + Console.WriteLine($"Environment variable not found: {constant}"); + validConfig = false; + } + + return validConfig; + } + + /// + /// Helper for the log configuration from environment variables. + /// + private static LoggerConfiguration UseLogLevel(this LoggerConfiguration conf, string level) => level.ToUpper() switch + { + "TRACE" => conf.MinimumLevel.Verbose(), + "DEBUG" => conf.MinimumLevel.Debug(), + "INFO" => conf.MinimumLevel.Information(), + "WARN" => conf.MinimumLevel.Warning(), + "ERROR" => conf.MinimumLevel.Error(), + _ => conf.MinimumLevel.Information() + }; + + } +} diff --git a/src/Guestbooky/Guestbooky.API/Properties/launchSettings.json b/src/Guestbooky/Guestbooky.API/Properties/launchSettings.json new file mode 100644 index 0000000..ecbcd56 --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/Properties/launchSettings.json @@ -0,0 +1,51 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ACCESS_USERNAME": "testy", + "ACCESS_PASSWORD": "tester", + "ACCESS_TOKENKEY": "youbetterbesureyouareusingatokenkey", + "ACCESS_ISSUER": "https://casorio.cotti.com.br/livro/api", + "ACCESS_AUDIENCE": "https://casorio.cotti.com.br/livro", + "CLOUDFLARE_SECRET": "0x4AAAAAAAiNCjAYE4Eeu1MIjSiHufs-2n0", + "MONGODB_CONNECTIONSTRING": "mongodb://guestbookyuser:guestbookypassword@localhost:27017/Guestbooky", + "MONGODB_DATABASENAME": "Guestbooky", + "CORS_ORIGINS": "https://casorio.cotti.com.br,http://localhost:8080,http://localhost:5008", + "LOG_LEVEL": "DEBUG" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5008" + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7196;http://localhost:5008" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:4774", + "sslPort": 44307 + } + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.API/Validations/InvalidModelStateResponse.cs b/src/Guestbooky/Guestbooky.API/Validations/InvalidModelStateResponse.cs new file mode 100644 index 0000000..8bf62ea --- /dev/null +++ b/src/Guestbooky/Guestbooky.API/Validations/InvalidModelStateResponse.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Guestbooky.API.Validations; + +public static class InvalidModelStateResponseFactory +{ + public static IActionResult DefaultInvalidModelStateResponse(ActionContext context) + { + var problemDetails = new ValidationProblemDetails(context.ModelState) + { + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", + Title = "One or more model validation errors occurred.", + Detail = "See the errors property for details", + Instance = context.HttpContext.Request.Path + }; + + problemDetails.Extensions.Add("traceId", context.HttpContext.TraceIdentifier); + + var rangeHeaderError = context.ModelState.FirstOrDefault(ms => ms.Key == "Range.Range" && ms.Value.Errors.Count > 0); + if (rangeHeaderError.Key != null) + { + if (rangeHeaderError.Value.Errors.Any(x => x.ErrorMessage.Contains("is not valid for Range."))) + { + // Return a 416 status code if a RangeHeaderValue-related error is found + return new ObjectResult(problemDetails) + { + StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable, + ContentTypes = { "application/problem+json" } + }; + } + else return null!; + } + return new ObjectResult(problemDetails) + { + StatusCode = StatusCodes.Status400BadRequest, + ContentTypes = { "application/problem+json" } + }; + } +} diff --git a/src/Guestbooky/Guestbooky.Application/Behaviors/ValidationBehavior.cs b/src/Guestbooky/Guestbooky.Application/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..c1e49d3 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/Behaviors/ValidationBehavior.cs @@ -0,0 +1,61 @@ +using FluentValidation; +using MediatR; + +namespace Guestbooky.Application.Behaviors; + +/// +/// This class provides a concrete implementation of the interface from MediatR. +/// Clear enough, of course, but what for? +/// +/// When you want to have some sort of validation of a Command's parameters, in order to avoid doing that +/// in Handle(), you build plumbing that does the happy path and the... other paths, for you. +/// +/// Isn't it nice? Yes. Isn't it also a hefty dose of over-engineering for a pretty small project? Yes, it is! +/// +/// But let's not lose track of the personal backscratcher ethos. We're here to show and learn too. +/// +/// When a command pops up, this will act as a middleware, or filter in .NET lingo. +/// It will be loaded with any found during initialization, and it will search for an +/// for that . +/// +/// (You will notice I'm actually using FluentValidation's , instead of implementing ; +/// It helped quite a bit and looks rather nice. It is also over-engineering.) +/// +/// Then, if it finds any validation errors, it throws a . Else, the pipeline goes on. +/// +/// The catch here is that something should catch it. in order to keep the presentation layer clean from Application layer behavior like this, +/// we can set up an that will join the plumbing to continue handling +/// from the point the exception occurs, and more refined treatment can be implemented then. +/// +/// That was a trip to figure out from scratch. But it is pretty interesting. +/// +public class ValidationBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (!_validators.Any()) + { + return await next(); + } + + var context = new ValidationContext(request); + var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults.SelectMany(r => r.Errors).Where(f => f is not null).ToArray(); + + if (failures.Length != 0) + { + throw new ValidationException(failures); + } + + return await next(); + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Application/DependencyInjection/DependencyInjection.cs b/src/Guestbooky/Guestbooky.Application/DependencyInjection/DependencyInjection.cs new file mode 100644 index 0000000..5b3775d --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/DependencyInjection/DependencyInjection.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using Guestbooky.Application.Behaviors; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Guestbooky.Application.DependencyInjection; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); + services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + + return services; + } +} diff --git a/src/Guestbooky/Guestbooky.Application/Guestbooky.Application.csproj b/src/Guestbooky/Guestbooky.Application/Guestbooky.Application.csproj new file mode 100644 index 0000000..659d0ef --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/Guestbooky.Application.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + AGPL-3.0-or-later + ..\..\..\LICENSE + Felipe Cotti + + + + + + + + + + + + + diff --git a/src/Guestbooky/Guestbooky.Application/Interfaces/ICaptchaVerifier.cs b/src/Guestbooky/Guestbooky.Application/Interfaces/ICaptchaVerifier.cs new file mode 100644 index 0000000..2e620c9 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/Interfaces/ICaptchaVerifier.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.Application.Interfaces +{ + public interface ICaptchaVerifier + { + public Task VerifyAsync(string challengeResponse, CancellationToken cancellationToken); + } +} diff --git a/src/Guestbooky/Guestbooky.Application/Interfaces/IJwtTokenService.cs b/src/Guestbooky/Guestbooky.Application/Interfaces/IJwtTokenService.cs new file mode 100644 index 0000000..d42c771 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/Interfaces/IJwtTokenService.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.Application.Interfaces; + +public interface IJwtTokenService +{ + string GenerateToken(string username); +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Application/Interfaces/IPasswordHasher.cs b/src/Guestbooky/Guestbooky.Application/Interfaces/IPasswordHasher.cs new file mode 100644 index 0000000..e5be883 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/Interfaces/IPasswordHasher.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.Application.Interfaces; + +public interface IPasswordHasher +{ + string HashPassword(string password); + bool VerifyPassword(string password, string passwordHash); +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Application/Interfaces/IRefreshTokenService.cs b/src/Guestbooky/Guestbooky.Application/Interfaces/IRefreshTokenService.cs new file mode 100644 index 0000000..ab89027 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/Interfaces/IRefreshTokenService.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.Application.Interfaces; + +public interface IRefreshTokenService +{ + string GenerateRefreshToken(); + ClaimsPrincipal ValidateRefreshToken(string refreshToken); + void SaveRefreshToken(string username, string refreshToken); +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Application/UseCases/AuthenticateUser/AuthenticateUserCommand.cs b/src/Guestbooky/Guestbooky.Application/UseCases/AuthenticateUser/AuthenticateUserCommand.cs new file mode 100644 index 0000000..c6efbfb --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/UseCases/AuthenticateUser/AuthenticateUserCommand.cs @@ -0,0 +1,41 @@ +using Guestbooky.Application.Interfaces; +using Guestbooky.Domain.Abstractions.Infrastructure; +using MediatR; + +namespace Guestbooky.Application.UseCases.AuthenticateUser; + +#region Types +public record AuthenticateUserCommand(string Username, string Password) : IRequest; +public record AuthenticateUserResult(bool IsAuthenticated, string Token, string RefreshToken); +#endregion + +public class AuthenticateUserCommandHandler : IRequestHandler +{ + private readonly IPasswordHasher _passwordHasher; + private readonly IUserCredentialsProvider _userCredentialsProvider; + private readonly IJwtTokenService _tokenGenerator; + private readonly IRefreshTokenService _refreshTokenService; + + public AuthenticateUserCommandHandler(IPasswordHasher passwordHasher, IJwtTokenService tokenGenerator, IUserCredentialsProvider userCredentialsProvider, IRefreshTokenService refreshTokenService) + { + _passwordHasher = passwordHasher; + _tokenGenerator = tokenGenerator; + _userCredentialsProvider = userCredentialsProvider; + _refreshTokenService = refreshTokenService; + } + + public Task Handle(AuthenticateUserCommand request, CancellationToken cancellationToken) + { + var user = _userCredentialsProvider.GetCredentials(); + if (request.Username != _userCredentialsProvider.GetCredentials().Username || !_passwordHasher.VerifyPassword(request.Password, user.PasswordHash)) + { + return Task.FromResult(new AuthenticateUserResult(false, string.Empty, string.Empty)); + } + + var token = _tokenGenerator.GenerateToken(request.Username); + var refreshToken = _refreshTokenService.GenerateRefreshToken(); + _refreshTokenService.SaveRefreshToken(request.Username, refreshToken); + + return Task.FromResult(new AuthenticateUserResult(true, token, refreshToken)); + } +} diff --git a/src/Guestbooky/Guestbooky.Application/UseCases/CountGuestbookMessages/CountGuestbookMessagesQuery.cs b/src/Guestbooky/Guestbooky.Application/UseCases/CountGuestbookMessages/CountGuestbookMessagesQuery.cs new file mode 100644 index 0000000..78dc7e6 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/UseCases/CountGuestbookMessages/CountGuestbookMessagesQuery.cs @@ -0,0 +1,28 @@ +using Guestbooky.Domain.Abstractions.Repositories; +using MediatR; + +namespace Guestbooky.Application.UseCases.CountGuestbookMessages; + +#region Types +public record CountGuestbookMessagesQuery() : IRequest; +public record CountGuestbookMessagesQueryResult(long Amount); +#endregion + +public class CountGuestbookMessagesQueryHandler : IRequestHandler +{ + private readonly IGuestbookMessageRepository _repository; + + public CountGuestbookMessagesQueryHandler(IGuestbookMessageRepository repository) + { + _repository = repository; + } + + public async Task Handle( + CountGuestbookMessagesQuery request, + CancellationToken cancellationToken) + { + var messageAmount = await _repository.CountAsync(cancellationToken); + + return new CountGuestbookMessagesQueryResult(messageAmount); + } +} diff --git a/src/Guestbooky/Guestbooky.Application/UseCases/DeleteGuestbookMessage/DeleteGuestbookMessageCommand.cs b/src/Guestbooky/Guestbooky.Application/UseCases/DeleteGuestbookMessage/DeleteGuestbookMessageCommand.cs new file mode 100644 index 0000000..c94a9d5 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/UseCases/DeleteGuestbookMessage/DeleteGuestbookMessageCommand.cs @@ -0,0 +1,25 @@ +using Guestbooky.Domain.Abstractions.Repositories; +using MediatR; + +namespace Guestbooky.Application.UseCases.DeleteGuestbookMessage; + +#region Types +public record DeleteGuestbookMessageCommand(string Id) : IRequest; +public record DeleteGuestbookMessageResult(bool Success); +#endregion + +public class DeleteGuestbookMessageCommandHandler : IRequestHandler +{ + private readonly IGuestbookMessageRepository _guestbookMessageRepository; + + public DeleteGuestbookMessageCommandHandler(IGuestbookMessageRepository guestbookMessageRepository) + { + _guestbookMessageRepository = guestbookMessageRepository; + } + + public async Task Handle(DeleteGuestbookMessageCommand request, CancellationToken cancellationToken) + { + var result = await _guestbookMessageRepository.DeleteAsync(request.Id, cancellationToken); + return new DeleteGuestbookMessageResult(result); + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Application/UseCases/InsertGuestbookMessage/InsertGuestbookMessageCommand.cs b/src/Guestbooky/Guestbooky.Application/UseCases/InsertGuestbookMessage/InsertGuestbookMessageCommand.cs new file mode 100644 index 0000000..4055453 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/UseCases/InsertGuestbookMessage/InsertGuestbookMessageCommand.cs @@ -0,0 +1,74 @@ +using FluentValidation; +using Guestbooky.Application.Interfaces; +using Guestbooky.Domain.Abstractions.Repositories; +using Guestbooky.Domain.Entities.Message; +using MediatR; +using MediatR.Pipeline; + +namespace Guestbooky.Application.UseCases.InsertGuestMessage; + +#region Types +public record InsertGuestbookMessageCommand(string Author, string Message, string CaptchaResponse) : IRequest; +public record InsertGuestbookMessageResult(bool Success, string? ErrorMessage); +#endregion + +public class InsertGuestbookMessageCommandHandler : IRequestHandler +{ + private readonly IGuestbookMessageRepository _guestbookMessageRepository; + + public InsertGuestbookMessageCommandHandler(IGuestbookMessageRepository guestbookMessageRepository) + { + _guestbookMessageRepository = guestbookMessageRepository; + } + + public async Task Handle(InsertGuestbookMessageCommand request, CancellationToken cancellationToken) + { + var newMessage = GuestbookMessage.Create(request.Author, request.Message); + await _guestbookMessageRepository.AddAsync(newMessage, cancellationToken); + return new InsertGuestbookMessageResult(true, string.Empty); + } +} + +#region Validation + +/// +/// Oh, this is where the interesting tidbit from the summary in is. +/// +/// This class inherits from FluentValidation's , so all that's needed is to write the rules +/// in the constructor. Very neat. +/// +/// This guarantees we have independent layers helping keep integrity on the (rather anemic) domain model. +/// Of course, there are improvements here and there we can think about. +/// If a rule fails here, we drop further below in this source file, to . +/// +public class InsertGuestbookMessageCommandValidator : AbstractValidator +{ + private readonly ICaptchaVerifier _captchaVerifier; + + public InsertGuestbookMessageCommandValidator(ICaptchaVerifier captchaVerifier) + { + _captchaVerifier = captchaVerifier; + + RuleFor(x => x.Author) + .NotEmpty().WithMessage("An author is required.") + .MaximumLength(200).WithMessage("The author field should not exceed 200 characters."); + RuleFor(x => x.Message) + .NotEmpty().WithMessage("A message is required.") + .MaximumLength(4096).WithMessage("The message field should not exceed 4096 characters."); + RuleFor(x => x.CaptchaResponse) + .NotEmpty().WithMessage("A captcha response must be sent with the payload.") + .MustAsync(async (captchaResponse, cancellationToken) => await _captchaVerifier.VerifyAsync(captchaResponse, cancellationToken)) + .WithMessage("The captcha challenge response was not accepted."); + } +} + +public class InsertGuestbookMessageCommandExceptionHandler : IRequestExceptionHandler +{ + public Task Handle(InsertGuestbookMessageCommand request, ValidationException exception, RequestExceptionHandlerState state, CancellationToken cancellationToken) + { + state.SetHandled(new InsertGuestbookMessageResult(false, string.Join(' ', exception.Errors))); + return Task.CompletedTask; + } +} + +#endregion \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Application/UseCases/ListGuestbookMessages/ListGuestbookMessagesQuery.cs b/src/Guestbooky/Guestbooky.Application/UseCases/ListGuestbookMessages/ListGuestbookMessagesQuery.cs new file mode 100644 index 0000000..0359515 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/UseCases/ListGuestbookMessages/ListGuestbookMessagesQuery.cs @@ -0,0 +1,31 @@ +using Guestbooky.Domain.Abstractions.Repositories; +using Guestbooky.Domain.Entities.Message; +using MediatR; + +namespace Guestbooky.Application.UseCases.ListGuestbookMessages; + +#region Types +public record ListGuestbookMessagesQuery(long Offset) : IRequest; +public record GuestbookMessageQueryResult(string Id, string Author, string Message, DateTimeOffset Timestamp); +public record ListGuestbookMessagesQueryResult(IEnumerable Messages); +#endregion + +public class ListGuestbookMessagesQueryHandler : IRequestHandler +{ + private readonly IGuestbookMessageRepository _repository; + + public ListGuestbookMessagesQueryHandler(IGuestbookMessageRepository repository) + { + _repository = repository; + } + + public async Task Handle( + ListGuestbookMessagesQuery request, + CancellationToken cancellationToken) + { + var messages = await _repository.GetAsync(request.Offset, cancellationToken); + + var queryResult = new ListGuestbookMessagesQueryResult(messages.Select(m => new GuestbookMessageQueryResult(m.Id.ToString(), m.Author!, m.Message!, m.Timestamp))); + return queryResult; + } +} diff --git a/src/Guestbooky/Guestbooky.Application/UseCases/RefreshToken/RefreshTokenCommand.cs b/src/Guestbooky/Guestbooky.Application/UseCases/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 0000000..1a67cbd --- /dev/null +++ b/src/Guestbooky/Guestbooky.Application/UseCases/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,37 @@ +using Guestbooky.Application.Interfaces; +using MediatR; +using System.Runtime.InteropServices; + +namespace Guestbooky.Application.UseCases.RefreshToken; + +#region Types +public record RefreshTokenCommand(string RefreshToken) : IRequest; +public record RefreshTokenResult(bool Success, string Token, string RefreshToken, string? ErrorMessage = null); +#endregion + +public class RefreshTokenCommandHandler : IRequestHandler +{ + private readonly IRefreshTokenService _refreshTokenService; + private readonly IJwtTokenService _jwtTokenService; + + public RefreshTokenCommandHandler(IRefreshTokenService refreshTokenService, IJwtTokenService jwtTokenService) + { + _refreshTokenService = refreshTokenService; + _jwtTokenService = jwtTokenService; + } + + public Task Handle(RefreshTokenCommand request, CancellationToken cancellationToken) + { + var principal = _refreshTokenService.ValidateRefreshToken(request.RefreshToken); + if (principal == null) + return Task.FromResult(new RefreshTokenResult(false, string.Empty, string.Empty, "Could not validate the cached refresh token.")); + + var username = principal.Identity!.Name!; + var newAccessToken = _jwtTokenService.GenerateToken(username); + var newRefreshToken = _refreshTokenService.GenerateRefreshToken(); + + _refreshTokenService.SaveRefreshToken(username, newRefreshToken); + + return Task.FromResult(new RefreshTokenResult(true, newAccessToken, newRefreshToken)); + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Domain/Abstractions/Infrastructure/IUserCredentialsProvider.cs b/src/Guestbooky/Guestbooky.Domain/Abstractions/Infrastructure/IUserCredentialsProvider.cs new file mode 100644 index 0000000..ad505ca --- /dev/null +++ b/src/Guestbooky/Guestbooky.Domain/Abstractions/Infrastructure/IUserCredentialsProvider.cs @@ -0,0 +1,15 @@ +using Guestbooky.Domain.Entities.User; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.Domain.Abstractions.Infrastructure; + +public interface IUserCredentialsProvider +{ + ApplicationUser GetCredentials(); + + ValueTask UpdateApplicationUser(ApplicationUser updated); +} diff --git a/src/Guestbooky/Guestbooky.Domain/Abstractions/Repositories/IGuestbookMessageRepository.cs b/src/Guestbooky/Guestbooky.Domain/Abstractions/Repositories/IGuestbookMessageRepository.cs new file mode 100644 index 0000000..f986094 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Domain/Abstractions/Repositories/IGuestbookMessageRepository.cs @@ -0,0 +1,17 @@ +using Guestbooky.Domain.Entities.Message; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Guestbooky.Domain.Abstractions.Repositories; + +public interface IGuestbookMessageRepository +{ + Task CountAsync(CancellationToken cancellationToken); + Task> GetAsync(long offset, CancellationToken cancellationToken); + Task AddAsync(GuestbookMessage message, CancellationToken cancellationToken); + Task DeleteAsync(string id, CancellationToken cancellationToken); +} diff --git a/src/Guestbooky/Guestbooky.Domain/Entities/Message/GuestbookMessage.cs b/src/Guestbooky/Guestbooky.Domain/Entities/Message/GuestbookMessage.cs new file mode 100644 index 0000000..1457c78 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Domain/Entities/Message/GuestbookMessage.cs @@ -0,0 +1,37 @@ +namespace Guestbooky.Domain.Entities.Message; + +/// +/// Rather slim! Not very DDD'd of me. Worked well enough though, but it's more of a DTO...? +/// Quick, think of weird stuff to do in a... simple guestbook manager! +/// +public record GuestbookMessage +{ + public required Guid Id { get; init; } + public required string Author { get; init; } + public required string Message { get; init; } + public required DateTimeOffset Timestamp { get; init; } + + private GuestbookMessage() { } + + public static GuestbookMessage Create(string author, string message) + { + return new GuestbookMessage + { + Id = Guid.NewGuid(), + Author = author, + Message = message, + Timestamp = DateTimeOffset.UtcNow + }; + } + + public static GuestbookMessage CreateExisting(Guid id, string author, string message, DateTimeOffset timestamp) + { + return new GuestbookMessage + { + Id = id, + Author = author, + Message = message, + Timestamp = timestamp + }; + } +} diff --git a/src/Guestbooky/Guestbooky.Domain/Entities/User/ApplicationUser.cs b/src/Guestbooky/Guestbooky.Domain/Entities/User/ApplicationUser.cs new file mode 100644 index 0000000..46a61cc --- /dev/null +++ b/src/Guestbooky/Guestbooky.Domain/Entities/User/ApplicationUser.cs @@ -0,0 +1,3 @@ +namespace Guestbooky.Domain.Entities.User; + +public record ApplicationUser(string Username, string PasswordHash); \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Domain/Guestbooky.Domain.csproj b/src/Guestbooky/Guestbooky.Domain/Guestbooky.Domain.csproj new file mode 100644 index 0000000..c1e532a --- /dev/null +++ b/src/Guestbooky/Guestbooky.Domain/Guestbooky.Domain.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + enable + enable + AGPL-3.0-or-later + ..\..\..\LICENSE + Felipe Cotti + + + diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Application/BcryptPasswordHasher.cs b/src/Guestbooky/Guestbooky.Infrastructure/Application/BcryptPasswordHasher.cs new file mode 100644 index 0000000..cb3b91e --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Application/BcryptPasswordHasher.cs @@ -0,0 +1,20 @@ +using Guestbooky.Application.Interfaces; + +namespace Guestbooky.Infrastructure.Application; + +/// +/// Modern BCrypt is so hard to use. I love it. +/// Weird edge cases, though. Keep your stuff under 72 characters. It'll be fine. +/// +public class BCryptPasswordHasher : IPasswordHasher +{ + public string HashPassword(string password) + { + return BCrypt.Net.BCrypt.EnhancedHashPassword(password); + } + + public bool VerifyPassword(string password, string passwordHash) + { + return BCrypt.Net.BCrypt.EnhancedVerify(password, passwordHash); + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Application/CloudflareCaptchaVerifier.cs b/src/Guestbooky/Guestbooky.Infrastructure/Application/CloudflareCaptchaVerifier.cs new file mode 100644 index 0000000..557338f --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Application/CloudflareCaptchaVerifier.cs @@ -0,0 +1,40 @@ +using Guestbooky.Application.Interfaces; +using Guestbooky.Infrastructure.DTOs.CloudflareCaptchaVerifier; +using Guestbooky.Infrastructure.Environment; +using Microsoft.Extensions.Configuration; +using System.Text; +using System.Text.Json; + +namespace Guestbooky.Infrastructure.Application +{ + /// + /// The captcha verification was the first thing I tried to make work, and work it did. + /// I remember not having ! Those were the days. I saw socket exhaustion. It's not pretty. + /// + public class CloudflareCaptchaVerifier : ICaptchaVerifier + { + private const string VERIFY_ADDRESS = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + private readonly string _secret; + private readonly IHttpClientFactory _httpClientFactory; + + public CloudflareCaptchaVerifier(IHttpClientFactory httpClientFactory, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNullOrWhiteSpace(configuration[Constants.CLOUDFLARE_SECRET]); + + _secret = configuration[Constants.CLOUDFLARE_SECRET]!; + _httpClientFactory = httpClientFactory; + } + public async Task VerifyAsync(string challengeResponse, CancellationToken cancellationToken) + { + using var httpClient = _httpClientFactory.CreateClient("API"); + + var request = new VerifyRequestDto() { Secret = _secret, Response = challengeResponse }; + var content = JsonSerializer.Serialize(request); + using var result = await httpClient.PostAsync(VERIFY_ADDRESS, new StringContent(content, Encoding.UTF8, "application/json"), cancellationToken); + var serialized = await JsonSerializer.DeserializeAsync(await result.Content.ReadAsStreamAsync(), cancellationToken: cancellationToken); + + return await Task.FromResult(serialized?.Success ?? false); + } + } +} diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Application/JwtTokenService.cs b/src/Guestbooky/Guestbooky.Infrastructure/Application/JwtTokenService.cs new file mode 100644 index 0000000..6354156 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Application/JwtTokenService.cs @@ -0,0 +1,61 @@ +using Guestbooky.Application.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Guestbooky.Infrastructure.Environment; + +namespace Guestbooky.Infrastructure.Application; + + +/// +/// Yeah, I'm one of those people. Jason Web Token Token. +/// +/// This implementation takes into account the simplistic nature of the guestbook, which assumes the admin is publishing it. +/// +/// Please don't use that suppress in work. +/// +public class JwtTokenService : IJwtTokenService +{ + private readonly SymmetricSecurityKey _key; + private readonly string _issuer; + private readonly string _audience; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Blocker Vulnerability", "S6781:JWT secret keys should not be disclosed", Justification = "Token key is granted via an environment variable")] + public JwtTokenService(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNullOrWhiteSpace(configuration[Constants.ACCESS_TOKENKEY]); + ArgumentNullException.ThrowIfNullOrWhiteSpace(configuration[Constants.ACCESS_ISSUER]); + ArgumentNullException.ThrowIfNullOrWhiteSpace(configuration[Constants.ACCESS_AUDIENCE]); + + _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration[Constants.ACCESS_TOKENKEY]!)); + _issuer = configuration[Constants.ACCESS_ISSUER]!; + _audience = configuration[Constants.ACCESS_AUDIENCE]!; + } + + public string GenerateToken(string username) + { + var claims = new List + { + new(ClaimTypes.Name, username), + }; + + var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddDays(7), + Issuer = _issuer, + Audience = _audience, + SigningCredentials = credentials + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + + return tokenHandler.WriteToken(token); + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Application/RefreshTokenService.cs b/src/Guestbooky/Guestbooky.Infrastructure/Application/RefreshTokenService.cs new file mode 100644 index 0000000..12ffb04 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Application/RefreshTokenService.cs @@ -0,0 +1,51 @@ +using Guestbooky.Application.Interfaces; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace Guestbooky.Infrastructure.Application; + +/// +/// Refreshing the token really feels like it needs a more serious implementation. +/// +/// Good luck! It should be easy. +/// We also could use a better caching mechanism. +/// +public class RefreshTokenService : IRefreshTokenService +{ + private static KeyValuePair _refreshToken = new(); + + public string GenerateRefreshToken() + { + var randomNumber = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber); + } + + public ClaimsPrincipal ValidateRefreshToken(string refreshToken) + { + if (_refreshToken.Key != refreshToken) + { + return null!; + } + + var username = _refreshToken.Value; + var claims = new List + { + new(ClaimTypes.Name, username) + }; + + var identity = new ClaimsIdentity(claims, "RefreshToken"); + return new ClaimsPrincipal(identity); + } + + private static void InternalSaveRefreshToken(string username, string refreshToken) + { + _refreshToken = new(refreshToken, username); + } + + public void SaveRefreshToken(string username, string refreshToken) + { + InternalSaveRefreshToken(username, refreshToken); + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Infrastructure/DTOs/CloudflareCaptchaVerifier/VerifyRequestDto.cs b/src/Guestbooky/Guestbooky.Infrastructure/DTOs/CloudflareCaptchaVerifier/VerifyRequestDto.cs new file mode 100644 index 0000000..5e11660 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/DTOs/CloudflareCaptchaVerifier/VerifyRequestDto.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Guestbooky.Infrastructure.DTOs.CloudflareCaptchaVerifier; +public record VerifyRequestDto +{ + [JsonPropertyName("secret")] + public string Secret { get; init; } = default!; + + [JsonPropertyName("response")] + public string Response { get; init; } = default!; + + [JsonPropertyName("remoteip")] + public string? RemoteIp { get; init; } + + [JsonPropertyName("idempotency_key")] + public string? IdempotencyKey { get; init; } +} diff --git a/src/Guestbooky/Guestbooky.Infrastructure/DTOs/CloudflareCaptchaVerifier/VerifyResultDto.cs b/src/Guestbooky/Guestbooky.Infrastructure/DTOs/CloudflareCaptchaVerifier/VerifyResultDto.cs new file mode 100644 index 0000000..704cef2 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/DTOs/CloudflareCaptchaVerifier/VerifyResultDto.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Guestbooky.Infrastructure.DTOs.CloudflareCaptchaVerifier; + +public record VerifyResultDto +{ + [JsonPropertyName("success")] + public bool Success { get; init; } + + [JsonPropertyName("challenge_ts")] + public DateTimeOffset? ChallengeTimestamp { get; init; } + + [JsonPropertyName("hostname")] + public string? Hostname { get; init; } + + [JsonPropertyName("error-codes")] + public List? ErrorCodes { get; init; } + + [JsonPropertyName("action")] + public string? Action { get; init; } + + [JsonPropertyName("cdata")] + public string? CData { get; init; } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Infrastructure/DependencyInjection/DependencyInjection.cs b/src/Guestbooky/Guestbooky.Infrastructure/DependencyInjection/DependencyInjection.cs new file mode 100644 index 0000000..e6dd768 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/DependencyInjection/DependencyInjection.cs @@ -0,0 +1,51 @@ +using Guestbooky.Application.Interfaces; +using Guestbooky.Domain.Abstractions.Infrastructure; +using Guestbooky.Domain.Abstractions.Repositories; +using Guestbooky.Infrastructure.Application; +using Guestbooky.Infrastructure.Environment; +using Guestbooky.Infrastructure.Persistence.Configurations; +using Guestbooky.Infrastructure.Persistence.Repositories; +using Guestbooky.Infrastructure.User; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; + +namespace Guestbooky.Infrastructure.DependencyInjection; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddHttpClient("API", o => + { + o.Timeout = TimeSpan.FromSeconds(10); + }); + + services.AddSingleton(new MongoDbSettings() + { + ConnectionString = configuration[Constants.MONGODB_CONNECTIONSTRING]!, + DatabaseName = configuration[Constants.MONGODB_DATABASENAME]! + }); + services.AddSingleton(o => + { + var settings = o.GetRequiredService()!; + return new MongoClient(settings.ConnectionString); + }); + services.AddScoped(o => + { + var client = o.GetRequiredService(); + var settings = o.GetRequiredService()!; + return client.GetDatabase(settings.DatabaseName); + }); + + services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + return services; + } +} diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Environment/Constants.cs b/src/Guestbooky/Guestbooky.Infrastructure/Environment/Constants.cs new file mode 100644 index 0000000..511d924 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Environment/Constants.cs @@ -0,0 +1,28 @@ +using MongoDB.Driver; +using System.Reflection; + +namespace Guestbooky.Infrastructure.Environment; + +/// +/// One of the main drawbacks of making this project easily settable from the environment was this being a bit convoluted. +/// Organized enough, but could be better. +/// +public static class Constants +{ + public const string ACCESS_USERNAME = "ACCESS_USERNAME"; + public const string ACCESS_PASSWORD = "ACCESS_PASSWORD"; + public const string ACCESS_TOKENKEY = "ACCESS_TOKENKEY"; + public const string ACCESS_ISSUER = "ACCESS_ISSUER"; + public const string ACCESS_AUDIENCE = "ACCESS_AUDIENCE"; + public const string CLOUDFLARE_SECRET = "CLOUDFLARE_SECRET"; + public const string MONGODB_CONNECTIONSTRING = "MONGODB_CONNECTIONSTRING"; + public const string MONGODB_DATABASENAME = "MONGODB_DATABASENAME"; + public const string CORS_ORIGINS = "CORS_ORIGINS"; + public const string LOG_LEVEL = "LOG_LEVEL"; + + public static List GetAllConstantKeys() + { + var fields = typeof(Constants).GetFields(BindingFlags.Public | BindingFlags.Static).Where(field => field.FieldType == typeof(string)); + return fields.Select(field => field.GetValue(null)?.ToString() ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + } +} diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Guestbooky.Infrastructure.csproj b/src/Guestbooky/Guestbooky.Infrastructure/Guestbooky.Infrastructure.csproj new file mode 100644 index 0000000..93265de --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Guestbooky.Infrastructure.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + AGPL-3.0-or-later + ..\..\..\LICENSE + Felipe Cotti + + + + + + + + + + + + + + + + diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Persistence/Configurations/MongoDbSettings.cs b/src/Guestbooky/Guestbooky.Infrastructure/Persistence/Configurations/MongoDbSettings.cs new file mode 100644 index 0000000..bfdf621 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Persistence/Configurations/MongoDbSettings.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.Infrastructure.Persistence.Configurations; + +public class MongoDbSettings +{ + public required string ConnectionString { get; set; } + public required string DatabaseName { get; set; } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Persistence/DTOs/MongoGuestbookMessageDto.cs b/src/Guestbooky/Guestbooky.Infrastructure/Persistence/DTOs/MongoGuestbookMessageDto.cs new file mode 100644 index 0000000..24aa31e --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Persistence/DTOs/MongoGuestbookMessageDto.cs @@ -0,0 +1,25 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.Infrastructure.Persistence.DTOs; + +public class GuestbookMessageDto +{ + [BsonId] + [BsonRepresentation(BsonType.String)] + public required string Id { get; set; } + + [BsonElement("Author")] + public required string Author { get; set; } + + [BsonElement("Message")] + public required string Message { get; set; } + + [BsonElement("Timestamp")] + public required DateTime Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.Infrastructure/Persistence/Repositories/MongoGuestbookMessageRepository.cs b/src/Guestbooky/Guestbooky.Infrastructure/Persistence/Repositories/MongoGuestbookMessageRepository.cs new file mode 100644 index 0000000..47eaef0 --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/Persistence/Repositories/MongoGuestbookMessageRepository.cs @@ -0,0 +1,88 @@ +using Guestbooky.Domain.Abstractions.Repositories; +using Guestbooky.Domain.Entities.Message; +using Guestbooky.Infrastructure.Persistence.DTOs; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Guestbooky.Infrastructure.Persistence.Repositories +{ + /// + /// First time ever using NoSQL here. I'm still not sure if I fell into any big anti-patterns. + /// As someone much more used to SQL lingo, this feels a bit weird but it is enjoyable. + /// Could use more robust error handling but I've yet to see it not working. + /// + public class MongoGuestbookMessageRepository : IGuestbookMessageRepository + { + private const string COLLECTION_NAME = "GuestbookMessages"; + + private readonly IMongoCollection _messages; + private readonly ILogger _logger; + + public MongoGuestbookMessageRepository(IMongoDatabase database, ILogger logger) + { + _messages = database.GetCollection(COLLECTION_NAME); + _logger = logger; + } + + public async Task AddAsync(GuestbookMessage message, CancellationToken cancellationToken) + { + _logger.LogDebug("Adding guestbook entry."); + + var messageDto = MapToDto(message); + await _messages.InsertOneAsync(messageDto, cancellationToken: cancellationToken); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + _logger.LogDebug($"Removing guestbook entry. Id: {id}"); + + var result = await _messages.DeleteOneAsync(m => m.Id == id, cancellationToken: cancellationToken); + + if (result.DeletedCount != 1) + _logger.LogError($"Deleted count is different from 1. Value: {result.DeletedCount}"); + if (!result.IsAcknowledged) + _logger.LogError("Deletion was not acknowledged."); + + return result.IsAcknowledged && result.DeletedCount == 1; + } + + public async Task> GetAsync(long offset, CancellationToken cancellationToken) + { + if (offset < 0) offset = 0; + _logger.LogDebug("Acquiring guestbook entries."); + var messageDtos = await _messages.Find(_ => true) + .SortBy(x => x.Timestamp) + .Skip((int?)offset) + .Limit(50) + .ToCursorAsync(cancellationToken); + return messageDtos.ToEnumerable().Select(MapToDomainModel).ToArray(); + } + + public async Task CountAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Getting amount of messages stored."); + return await _messages.CountDocumentsAsync(_ => true, cancellationToken: cancellationToken); + } + + private static GuestbookMessage MapToDomainModel(GuestbookMessageDto dto) + { + return GuestbookMessage.CreateExisting( + Guid.Parse(dto.Id), + dto.Author, + dto.Message, + new DateTimeOffset(dto.Timestamp, TimeSpan.Zero) + ); + } + + private static GuestbookMessageDto MapToDto(GuestbookMessage message) + { + return new GuestbookMessageDto + { + Id = message.Id.ToString(), + Author = message.Author, + Message = message.Message, + Timestamp = message.Timestamp.UtcDateTime + }; + } + } +} diff --git a/src/Guestbooky/Guestbooky.Infrastructure/User/EnvironmentUserCredentialsProvider.cs b/src/Guestbooky/Guestbooky.Infrastructure/User/EnvironmentUserCredentialsProvider.cs new file mode 100644 index 0000000..b38876b --- /dev/null +++ b/src/Guestbooky/Guestbooky.Infrastructure/User/EnvironmentUserCredentialsProvider.cs @@ -0,0 +1,29 @@ +using Guestbooky.Application.Interfaces; +using Guestbooky.Domain.Abstractions.Infrastructure; +using Guestbooky.Domain.Entities.User; +using Guestbooky.Infrastructure.Environment; +using Microsoft.Extensions.Configuration; + +namespace Guestbooky.Infrastructure.User; + +public class EnvironmentUserCredentialsProvider : IUserCredentialsProvider +{ + private ApplicationUser _user; + + public EnvironmentUserCredentialsProvider(IConfiguration configuration, IPasswordHasher passwordHasher) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNullOrWhiteSpace(configuration[Constants.ACCESS_USERNAME]); + ArgumentNullException.ThrowIfNullOrWhiteSpace(configuration[Constants.ACCESS_PASSWORD]); + + _user = new ApplicationUser(configuration[Constants.ACCESS_USERNAME]!, passwordHasher.HashPassword(configuration[Constants.ACCESS_PASSWORD]!)); + } + + public ApplicationUser GetCredentials() => _user; + + public ValueTask UpdateApplicationUser(ApplicationUser updated) + { + _user = updated; + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.UnitTests/UnitTest1.cs b/src/Guestbooky/Guestbooky.UnitTests/UnitTest1.cs new file mode 100644 index 0000000..687bbd4 --- /dev/null +++ b/src/Guestbooky/Guestbooky.UnitTests/UnitTest1.cs @@ -0,0 +1,11 @@ +namespace Guestbooky.UnitTests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} \ No newline at end of file diff --git a/src/Guestbooky/Guestbooky.sln b/src/Guestbooky/Guestbooky.sln new file mode 100644 index 0000000..fd3f910 --- /dev/null +++ b/src/Guestbooky/Guestbooky.sln @@ -0,0 +1,49 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35013.160 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Guestbooky.API", "Guestbooky.API\Guestbooky.API.csproj", "{478BC4F1-3952-4562-B7C1-1CBC8F07AF14}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Guestbooky.Domain", "Guestbooky.Domain\Guestbooky.Domain.csproj", "{3023D8C6-B6D5-42B8-8387-D85F06B353A6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Guestbooky.Application", "Guestbooky.Application\Guestbooky.Application.csproj", "{EB283A09-68B9-47A0-801B-698EAD99121F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Guestbooky.Infrastructure", "Guestbooky.Infrastructure\Guestbooky.Infrastructure.csproj", "{D851911C-F268-4D28-83B5-9240C153449F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Guestbooky.UnitTests", "..\..\tests\Guestbooky.UnitTests\Guestbooky.UnitTests.csproj", "{8B565439-7C90-4FF9-A9F5-A1F7B28051A8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {478BC4F1-3952-4562-B7C1-1CBC8F07AF14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {478BC4F1-3952-4562-B7C1-1CBC8F07AF14}.Debug|Any CPU.Build.0 = Debug|Any CPU + {478BC4F1-3952-4562-B7C1-1CBC8F07AF14}.Release|Any CPU.ActiveCfg = Release|Any CPU + {478BC4F1-3952-4562-B7C1-1CBC8F07AF14}.Release|Any CPU.Build.0 = Release|Any CPU + {3023D8C6-B6D5-42B8-8387-D85F06B353A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3023D8C6-B6D5-42B8-8387-D85F06B353A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3023D8C6-B6D5-42B8-8387-D85F06B353A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3023D8C6-B6D5-42B8-8387-D85F06B353A6}.Release|Any CPU.Build.0 = Release|Any CPU + {EB283A09-68B9-47A0-801B-698EAD99121F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB283A09-68B9-47A0-801B-698EAD99121F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB283A09-68B9-47A0-801B-698EAD99121F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB283A09-68B9-47A0-801B-698EAD99121F}.Release|Any CPU.Build.0 = Release|Any CPU + {D851911C-F268-4D28-83B5-9240C153449F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D851911C-F268-4D28-83B5-9240C153449F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D851911C-F268-4D28-83B5-9240C153449F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D851911C-F268-4D28-83B5-9240C153449F}.Release|Any CPU.Build.0 = Release|Any CPU + {8B565439-7C90-4FF9-A9F5-A1F7B28051A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B565439-7C90-4FF9-A9F5-A1F7B28051A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B565439-7C90-4FF9-A9F5-A1F7B28051A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B565439-7C90-4FF9-A9F5-A1F7B28051A8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {00088182-D5D2-493E-B9BB-5371491ACC95} + EndGlobalSection +EndGlobal diff --git a/src/input-example/example.html b/src/input-example/example.html new file mode 100644 index 0000000..69eced1 --- /dev/null +++ b/src/input-example/example.html @@ -0,0 +1,155 @@ + + + + + + + Guestbook usage example with the captcha + + + + + +
+

Sample guestbooky input page

+ +
+ + + + + + + + + + + + + +
+ +
+ + + + +
+
+ + + + + + diff --git a/tests/Guestbooky.UnitTests/API/Controllers/AuthControllerTests.cs b/tests/Guestbooky.UnitTests/API/Controllers/AuthControllerTests.cs new file mode 100644 index 0000000..fa6a39a --- /dev/null +++ b/tests/Guestbooky.UnitTests/API/Controllers/AuthControllerTests.cs @@ -0,0 +1,153 @@ +using Guestbooky.API.Controllers; +using Guestbooky.API.DTOs.Auth; +using Guestbooky.Application.UseCases.AuthenticateUser; +using Guestbooky.Application.UseCases.RefreshToken; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using System.Net; + +namespace Guestbooky.UnitTests.API.Controllers; + +public class AuthControllerTests +{ + private readonly Mock _mediatorMock; + private readonly Mock> _loggerMock; + + public AuthControllerTests() + { + _mediatorMock = new Mock(); + _loggerMock = new Mock>(); + } + + [Fact] + public async Task Login_ReturnsOk_WhenAuthenticated() + { + // Arrange + var controller = new AuthController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new LoginRequestDto("testuser", "password"); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new AuthenticateUserResult(true, "testToken", "testRefreshToken")); + + // Act + var result = await controller.Login(requestDto, default); + + // Assert + var okResult = Assert.IsType(result); + var responseDto = Assert.IsType(okResult.Value); + Assert.Equal("testToken", responseDto.Token); + Assert.Equal("testRefreshToken", responseDto.RefreshToken); + } + + [Fact] + public async Task Login_ReturnsUnauthorized_WhenNotAuthenticated() + { + // Arrange + var controller = new AuthController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new LoginRequestDto("testuser", "wrongpassword"); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new AuthenticateUserResult(false, string.Empty, string.Empty)); + + // Act + var result = await controller.Login(requestDto, default); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public async Task Login_ReturnsProblemDetails_WhenExceptionIsThrown() + { + // Arrange + var controller = new AuthController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new LoginRequestDto("testuser", "password"); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ThrowsAsync(new Exception()); + + // Act + var result = await controller.Login(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.Equal(500, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.StartsWith("An error occurred on the server:", problemDetails.Detail); + + } + + [Fact] + public async Task RefreshToken_ReturnsOk_WhenTokenMatches() + { + // Arrange + var controller = new AuthController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new RefreshTokenRequestDto("refresh"); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new RefreshTokenResult(true, "testToken", "testRefreshToken")); + + // Act + var result = await controller.RefreshToken(requestDto, default); + + // Assert + Assert.NotNull(result); + var okResult = Assert.IsType(result); + var responseDto = Assert.IsType(okResult.Value); + Assert.Equal("testToken", responseDto.Token); + Assert.Equal("testRefreshToken", responseDto.RefreshToken); + } + + [Fact] + public async Task RefreshToken_ReturnsUnauthorized_WhenTokenNotMatched() + { + // Arrange + var controller = new AuthController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new RefreshTokenRequestDto("refresh"); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new RefreshTokenResult(false, string.Empty, string.Empty, "error")); + + // Act + var result = await controller.RefreshToken(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.NotNull(result); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal("error", problemDetails.Detail); + } + + [Fact] + public async Task RefreshToken_ReturnsProblemDetails_WhenExceptionIsThrown() + { + // Arrange + var controller = new AuthController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new RefreshTokenRequestDto("refresh"); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ThrowsAsync(new Exception()); + + // Act + var result = await controller.RefreshToken(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.Equal(500, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.StartsWith("An error occurred on the server:", problemDetails.Detail); + } +} \ No newline at end of file diff --git a/tests/Guestbooky.UnitTests/API/Controllers/MessageControllerTests.cs b/tests/Guestbooky.UnitTests/API/Controllers/MessageControllerTests.cs new file mode 100644 index 0000000..80c63fa --- /dev/null +++ b/tests/Guestbooky.UnitTests/API/Controllers/MessageControllerTests.cs @@ -0,0 +1,280 @@ +using Guestbooky.API.Controllers; +using Guestbooky.API.DTOs.Messages; +using Guestbooky.Application.UseCases.CountGuestbookMessages; +using Guestbooky.Application.UseCases.DeleteGuestbookMessage; +using Guestbooky.Application.UseCases.InsertGuestMessage; +using Guestbooky.Application.UseCases.ListGuestbookMessages; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Guestbooky.UnitTests.API.Controllers; + +public class MessageControllerTests +{ + private readonly Mock _mediatorMock; + private readonly Mock> _loggerMock; + + public MessageControllerTests() + { + _mediatorMock = new Mock(); + _loggerMock = new Mock>(); + } + + #region Insert() + [Fact] + public async Task Insert_ReturnsCreated_WhenValidMessageSent() + { + // Arrange + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new InsertMessageRequestDto() { Author = "tester", Message = "message", CaptchaResponse = "validCaptcha" }; + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new InsertGuestbookMessageResult(true, string.Empty)); + + // Act + var result = await controller.Insert(requestDto, default); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task Insert_ReturnsForbidden_WhenInvalidInputProvided() + { + // Arrange + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new InsertMessageRequestDto() { Author = "tester", Message = "message", CaptchaResponse = "invalidCaptcha" }; + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new InsertGuestbookMessageResult(false, "An invalid captcha has been provided")); + + // Act + var result = await controller.Insert(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.Equal(403, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal("An invalid captcha has been provided", problemDetails.Detail); + } + + [Fact] + public async Task Insert_ReturnsProblemDetails_WhenExceptionIsThrown() + { + // Arrange + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new InsertMessageRequestDto() { Author = "tester", Message = "message", CaptchaResponse = "invalidCaptcha" }; + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ThrowsAsync(new Exception()); + + // Act + var result = await controller.Insert(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.Equal(500, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.StartsWith("An error occurred on the server:", problemDetails.Detail); + } + + #endregion + + #region Get + [Fact] + public async Task Get_ReturnsMessages_WhenRangeValueSent() + { + // Arrange + var httpContextMock = new Mock(); + var httpResponseMock = new Mock(); + var headers = new HeaderDictionary(); + + httpResponseMock.SetupProperty(r => r.StatusCode); + httpResponseMock.SetupGet(r => r.Headers).Returns(headers); + httpContextMock.SetupGet(h => h.Response).Returns(httpResponseMock.Object); + + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object) + { + ControllerContext = new ControllerContext() + { + HttpContext = httpContextMock.Object + } + }; + + var requestedRange = new System.Net.Http.Headers.RangeItemHeaderValue(1, 3); + List availableMessages = [ + new GuestbookMessageQueryResult("test-id1", "test-author", "test-message1", DateTimeOffset.UnixEpoch.AddDays(1)), + new GuestbookMessageQueryResult("test-id2", "test-author", "test-message2", DateTimeOffset.UnixEpoch.AddDays(2)), + new GuestbookMessageQueryResult("test-id3", "test-author", "test-message3", DateTimeOffset.UnixEpoch.AddDays(3)), + new GuestbookMessageQueryResult("test-id4", "test-author", "test-message4", DateTimeOffset.UnixEpoch.AddDays(4)), + new GuestbookMessageQueryResult("test-id5", "test-author", "test-message5", DateTimeOffset.UnixEpoch.AddDays(5)), + ]; + var intendedMessages = availableMessages.Skip((int)requestedRange.From! - 1).Take((int)requestedRange.From! - 1 + (int)requestedRange.To!); + + var requestDto = new GetMessagesRequestDto() { Range = new System.Net.Http.Headers.RangeHeaderValue() { Unit = "messages" } }; + requestDto.Range.Ranges.Add(requestedRange); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new ListGuestbookMessagesQueryResult(intendedMessages)); + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new CountGuestbookMessagesQueryResult(availableMessages.Count)); + + // Act + var result = await controller.Get(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.IsAssignableFrom>(objectResult.Value); + + httpResponseMock.VerifySet(r => r.StatusCode = StatusCodes.Status206PartialContent); + Assert.Equal("messages", headers["Accept-Ranges"]); + Assert.Contains("messages", headers["Content-Range"].ToString()); + } + + [Fact] + public async Task Get_ReturnsMessages_WithNoRange() + { + // Arrange + var httpContextMock = new Mock(); + var httpResponseMock = new Mock(); + var headers = new HeaderDictionary(); + + httpResponseMock.SetupProperty(r => r.StatusCode); + httpResponseMock.SetupGet(r => r.Headers).Returns(headers); + httpContextMock.SetupGet(h => h.Response).Returns(httpResponseMock.Object); + + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object) + { + ControllerContext = new ControllerContext() + { + HttpContext = httpContextMock.Object + } + }; + + List availableMessages = [ + new GuestbookMessageQueryResult("test-id1", "test-author", "test-message1", DateTimeOffset.UnixEpoch.AddDays(1)), + new GuestbookMessageQueryResult("test-id2", "test-author", "test-message2", DateTimeOffset.UnixEpoch.AddDays(2)), + new GuestbookMessageQueryResult("test-id3", "test-author", "test-message3", DateTimeOffset.UnixEpoch.AddDays(3)), + new GuestbookMessageQueryResult("test-id4", "test-author", "test-message4", DateTimeOffset.UnixEpoch.AddDays(4)), + new GuestbookMessageQueryResult("test-id5", "test-author", "test-message5", DateTimeOffset.UnixEpoch.AddDays(5)), + ]; + var intendedMessages = availableMessages.Skip(0).Take(50); + + var requestDto = new GetMessagesRequestDto() { Range = null! }; + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new ListGuestbookMessagesQueryResult(intendedMessages)); + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new CountGuestbookMessagesQueryResult(availableMessages.Count)); + + // Act + var result = await controller.Get(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.IsAssignableFrom>(objectResult.Value); + + httpResponseMock.VerifySet(r => r.StatusCode = StatusCodes.Status206PartialContent); + Assert.Equal("messages", headers["Accept-Ranges"]); + Assert.Contains("messages", headers["Content-Range"].ToString()); + } + + [Fact] + public async Task Get_ReturnsProblemDetails_WhenExceptionIsThrown() + { + // Arrange + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new GetMessagesRequestDto() { Range = null! }; + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ThrowsAsync(new Exception()); + + // Act + var result = await controller.Get(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.Equal(500, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.StartsWith("An error occurred on the server:", problemDetails.Detail); + } + + #endregion + + #region Delete + [Fact] + public async Task Delete_ReturnsOk_WithValidMessage() + { + // Arrange + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new DeleteMessageRequestDto() { Id = "test_id" }; + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new DeleteGuestbookMessageResult(true)); + + // Act + var result = await controller.Delete(requestDto, default); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task Delete_ReturnsForbidden_WhenInvalidInputProvided() + { + // Arrange + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new DeleteMessageRequestDto() { Id = "invalid_test_id" }; + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(new DeleteGuestbookMessageResult(false)); + + // Act + var result = await controller.Delete(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.Equal(500, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.StartsWith("Could not delete the guestbook entry", problemDetails.Detail); + } + + [Fact] + public async Task Delete_ReturnsProblemDetails_WhenExceptionIsThrown() + { + // Arrange + var controller = new MessageController(_mediatorMock.Object, _loggerMock.Object); + + var requestDto = new DeleteMessageRequestDto() { Id = "invalid_test_id" }; + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ThrowsAsync(new Exception()); + + // Act + var result = await controller.Delete(requestDto, default); + + // Assert + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + Assert.Equal(500, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.StartsWith("An error occurred on the server:", problemDetails.Detail); + } + #endregion + +} diff --git a/tests/Guestbooky.UnitTests/API/Validations/InvalidModelStateResponseFactoryTests.cs b/tests/Guestbooky.UnitTests/API/Validations/InvalidModelStateResponseFactoryTests.cs new file mode 100644 index 0000000..70b6f79 --- /dev/null +++ b/tests/Guestbooky.UnitTests/API/Validations/InvalidModelStateResponseFactoryTests.cs @@ -0,0 +1,120 @@ +using Guestbooky.API.Validations; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.UnitTests.API.Validations +{ + public class InvalidModelStateResponseFactoryTests + { + [Fact] + public async Task DefaultInvalidModelStateResponse_InvalidActionContext_ReturnsBadRequest() + { + // Arrange + var httpContextMock = new Mock(); + var httpRequestMock = new Mock(); + var httpResponseMock = new Mock(); + var headers = new HeaderDictionary(); + + httpResponseMock.SetupProperty(r => r.StatusCode); + httpResponseMock.SetupGet(r => r.Headers).Returns(headers); + httpContextMock.SetupGet(h => h.Request).Returns(httpRequestMock.Object); + httpContextMock.SetupGet(h => h.Response).Returns(httpResponseMock.Object); + + var modelState = new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary(); + modelState.AddModelError("test", "test error"); + + var actionContext = new ActionContext(httpContextMock.Object, + new Microsoft.AspNetCore.Routing.RouteData(), + new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor(), + modelState); + + // Act + var result = InvalidModelStateResponseFactory.DefaultInvalidModelStateResponse(actionContext); + + // Assert + Assert.NotNull(result); + + var objectResult = Assert.IsType(result); + Assert.NotNull(objectResult); + Assert.Equal(400, objectResult.StatusCode); + Assert.Equal("application/problem+json", objectResult.ContentTypes[0]); + + var problemDetails = Assert.IsType(objectResult.Value); + Assert.NotNull(problemDetails); + Assert.Equal("test error", problemDetails.Errors["test"][0]); + } + + [Fact] + public async Task DefaultInvalidModelStateResponse_BadRangeValue_ReturnsRequestedRangeNotSatisfiable() + { + // Arrange + var httpContextMock = new Mock(); + var httpRequestMock = new Mock(); + var httpResponseMock = new Mock(); + var headers = new HeaderDictionary(); + + httpResponseMock.SetupProperty(r => r.StatusCode); + httpResponseMock.SetupGet(r => r.Headers).Returns(headers); + httpContextMock.SetupGet(h => h.Request).Returns(httpRequestMock.Object); + httpContextMock.SetupGet(h => h.Response).Returns(httpResponseMock.Object); + + var modelState = new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary(); + modelState.AddModelError("Range.Range", "A test value is not valid for Range."); + + var actionContext = new ActionContext(httpContextMock.Object, + new Microsoft.AspNetCore.Routing.RouteData(), + new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor(), + modelState); + + // Act + var result = InvalidModelStateResponseFactory.DefaultInvalidModelStateResponse(actionContext); + + // Assert + Assert.NotNull(result); + + var objectResult = Assert.IsType(result); + Assert.NotNull(objectResult); + Assert.Equal(416, objectResult.StatusCode); + Assert.Equal("application/problem+json", objectResult.ContentTypes[0]); + + var problemDetails = Assert.IsType(objectResult.Value); + Assert.NotNull(problemDetails); + Assert.Equal("A test value is not valid for Range.", problemDetails.Errors["Range.Range"][0]); + } + + [Fact] + public async Task DefaultInvalidModelStateResponse_NullRangeError_ReturnsNull() + { + // Arrange + var httpContextMock = new Mock(); + var httpRequestMock = new Mock(); + var httpResponseMock = new Mock(); + var headers = new HeaderDictionary(); + + httpResponseMock.SetupProperty(r => r.StatusCode); + httpResponseMock.SetupGet(r => r.Headers).Returns(headers); + httpContextMock.SetupGet(h => h.Request).Returns(httpRequestMock.Object); + httpContextMock.SetupGet(h => h.Response).Returns(httpResponseMock.Object); + + var modelState = new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary(); + modelState.AddModelError("Range.Range", "Range here would have been delivered as something invalid."); + + var actionContext = new ActionContext(httpContextMock.Object, + new Microsoft.AspNetCore.Routing.RouteData(), + new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor(), + modelState); + + // Act + var result = InvalidModelStateResponseFactory.DefaultInvalidModelStateResponse(actionContext); + + // Assert + Assert.Null(result); + } + } +} diff --git a/tests/Guestbooky.UnitTests/Application/UseCases/AuthenticateUserCommandTests.cs b/tests/Guestbooky.UnitTests/Application/UseCases/AuthenticateUserCommandTests.cs new file mode 100644 index 0000000..2465a6c --- /dev/null +++ b/tests/Guestbooky.UnitTests/Application/UseCases/AuthenticateUserCommandTests.cs @@ -0,0 +1,107 @@ +using Guestbooky.Application.Interfaces; +using Guestbooky.Application.UseCases.AuthenticateUser; +using Guestbooky.Domain.Abstractions.Infrastructure; +using Guestbooky.Domain.Entities.User; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.UnitTests.Application.UseCases; + +public class AuthenticateUserCommandHandlerTests +{ + private readonly Mock _passwordHasherMock; + private readonly Mock _userCredentialsProviderMock; + private readonly Mock _jwtTokenServiceMock; + private readonly Mock _refreshTokenServiceMock; + private readonly AuthenticateUserCommandHandler _handler; + + public AuthenticateUserCommandHandlerTests() + { + _passwordHasherMock = new Mock(); + _userCredentialsProviderMock = new Mock(); + _jwtTokenServiceMock = new Mock(); + _refreshTokenServiceMock = new Mock(); + + _handler = new AuthenticateUserCommandHandler( + _passwordHasherMock.Object, + _jwtTokenServiceMock.Object, + _userCredentialsProviderMock.Object, + _refreshTokenServiceMock.Object + ); + } + + [Fact] + public async Task Handle_WithValidCredentials_ReturnsSuccessfulAuthentication() + { + // Arrange + var command = new AuthenticateUserCommand("testuser", "testpass"); + + var userCredentials = new ApplicationUser("testuser","hashpass"); + var expectedToken = "token"; + var expectedRefreshToken = "refresh"; + + _userCredentialsProviderMock.Setup(x => x.GetCredentials()).Returns(userCredentials); + _passwordHasherMock.Setup(x => x.VerifyPassword(It.IsAny(), It.IsAny())).Returns(true); + _jwtTokenServiceMock.Setup(x => x.GenerateToken(It.IsAny())).Returns(expectedToken); + _refreshTokenServiceMock.Setup(x => x.GenerateRefreshToken()).Returns(expectedRefreshToken); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.True(result.IsAuthenticated); + Assert.Equal(expectedToken, result.Token); + Assert.Equal(expectedRefreshToken, result.RefreshToken); + + _refreshTokenServiceMock.Verify(x => x.SaveRefreshToken(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithInvalidUsername_ReturnsFailedAuthentication() + { + // Arrange + var command = new AuthenticateUserCommand("wronguser", "userpass"); + var userCredentials = new ApplicationUser("testuser", "hashpass"); + + _userCredentialsProviderMock.Setup(x => x.GetCredentials()).Returns(userCredentials); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.False(result.IsAuthenticated); + Assert.Equal(string.Empty, result.Token); + Assert.Equal(string.Empty, result.RefreshToken); + + _jwtTokenServiceMock.Verify(x => x.GenerateToken(It.IsAny()), Times.Never); + _refreshTokenServiceMock.Verify(x => x.GenerateRefreshToken(), Times.Never); + _refreshTokenServiceMock.Verify(x => x.SaveRefreshToken(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithInvalidPassword_ReturnsFailedAuthentication() + { + // Arrange + var command = new AuthenticateUserCommand("testuser", "wrongpass"); + var userCredentials = new ApplicationUser("testuser", "hashpass"); + + _userCredentialsProviderMock.Setup(x => x.GetCredentials()).Returns(userCredentials); + _passwordHasherMock.Setup(x => x.VerifyPassword(It.IsAny(), It.IsAny())).Returns(false); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.False(result.IsAuthenticated); + Assert.Equal(string.Empty, result.Token); + Assert.Equal(string.Empty, result.RefreshToken); + + _jwtTokenServiceMock.Verify(x => x.GenerateToken(It.IsAny()), Times.Never); + _refreshTokenServiceMock.Verify(x => x.GenerateRefreshToken(), Times.Never); + _refreshTokenServiceMock.Verify(x => x.SaveRefreshToken(It.IsAny(), It.IsAny()), Times.Never); + } +} \ No newline at end of file diff --git a/tests/Guestbooky.UnitTests/Application/UseCases/CountGuestbookMessagesQueryTests.cs b/tests/Guestbooky.UnitTests/Application/UseCases/CountGuestbookMessagesQueryTests.cs new file mode 100644 index 0000000..304b87a --- /dev/null +++ b/tests/Guestbooky.UnitTests/Application/UseCases/CountGuestbookMessagesQueryTests.cs @@ -0,0 +1,39 @@ +using Guestbooky.Application.UseCases.CountGuestbookMessages; +using Guestbooky.Domain.Abstractions.Repositories; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.UnitTests.Application.UseCases; + +public class CountGuestbookMessagesQueryHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly CountGuestbookMessagesQueryHandler _handler; + + public CountGuestbookMessagesQueryHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new CountGuestbookMessagesQueryHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_ReturnsCorrectCount() + { + // Arrange + const long expectedCount = 42; + _repositoryMock.Setup(x => x.CountAsync(It.IsAny())).ReturnsAsync(expectedCount); + + var query = new CountGuestbookMessagesQuery(); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + Assert.Equal(expectedCount, result.Amount); + _repositoryMock.Verify(x => x.CountAsync(It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/tests/Guestbooky.UnitTests/Application/UseCases/DeleteGuestbookMessageCommandTests.cs b/tests/Guestbooky.UnitTests/Application/UseCases/DeleteGuestbookMessageCommandTests.cs new file mode 100644 index 0000000..ece01b7 --- /dev/null +++ b/tests/Guestbooky.UnitTests/Application/UseCases/DeleteGuestbookMessageCommandTests.cs @@ -0,0 +1,76 @@ +using Guestbooky.Application.UseCases.DeleteGuestbookMessage; +using Guestbooky.Domain.Abstractions.Repositories; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.UnitTests.Application.UseCases; + +public class DeleteGuestbookMessageCommandHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly DeleteGuestbookMessageCommandHandler _handler; + + public DeleteGuestbookMessageCommandHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new DeleteGuestbookMessageCommandHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_HasMessage_ReturnsSuccessTrue() + { + // Arrange + var messageId = "existing-message-id"; + _repositoryMock.Setup(x => x.DeleteAsync(messageId, It.IsAny())).ReturnsAsync(true); + + var command = new DeleteGuestbookMessageCommand(messageId); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.True(result.Success); + _repositoryMock.Verify(x => x.DeleteAsync(messageId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_NoMessage_ReturnsFalse() + { + // Arrange + var messageId = "non-existent-message-id"; + _repositoryMock.Setup(x => x.DeleteAsync(messageId, It.IsAny())) + .ReturnsAsync(false); + + var command = new DeleteGuestbookMessageCommand(messageId); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.False(result.Success); + _repositoryMock.Verify(x => x.DeleteAsync(messageId, It.IsAny()), Times.Once); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public async Task Handle_WithInvalidIds_StillAttemptsDelete(string invalidId) + { + // Arrange + _repositoryMock.Setup(x => x.DeleteAsync(invalidId, It.IsAny())) + .ReturnsAsync(false); + + var command = new DeleteGuestbookMessageCommand(invalidId); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.False(result.Success); + _repositoryMock.Verify(x => x.DeleteAsync(invalidId, It.IsAny()), Times.Once); + } +} diff --git a/tests/Guestbooky.UnitTests/Application/UseCases/InsertGuestbookMessageCommandTests.cs b/tests/Guestbooky.UnitTests/Application/UseCases/InsertGuestbookMessageCommandTests.cs new file mode 100644 index 0000000..d9cb0d7 --- /dev/null +++ b/tests/Guestbooky.UnitTests/Application/UseCases/InsertGuestbookMessageCommandTests.cs @@ -0,0 +1,195 @@ +using FluentValidation; +using FluentValidation.Results; +using Guestbooky.Application.Interfaces; +using Guestbooky.Application.UseCases.InsertGuestMessage; +using Guestbooky.Domain.Abstractions.Repositories; +using Guestbooky.Domain.Entities.Message; +using MediatR.Pipeline; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.UnitTests.Application.UseCases; + +public static class InsertGuestbookMessageTests +{ + public class CommandHandlerTests + { + private readonly Mock _repositoryMock; + private readonly InsertGuestbookMessageCommandHandler _handler; + + public CommandHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new InsertGuestbookMessageCommandHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_WithValidCommand_InsertsMessage() + { + // Arrange + var command = new InsertGuestbookMessageCommand("Asdrubal", "Perdi!", "captcha-token"); + GuestbookMessage? capturedMessage = null; + + _repositoryMock.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((message, _) => capturedMessage = message) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.Empty(result.ErrorMessage!); + + Assert.NotNull(capturedMessage); + Assert.Equal("Asdrubal", capturedMessage.Author); + Assert.Equal("Perdi!", capturedMessage.Message); + Assert.True(capturedMessage.Id != Guid.Empty); + Assert.True(capturedMessage.Timestamp <= DateTimeOffset.UtcNow); + + _repositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } + } + + public class InsertGuestbookMessageCommandValidatorTests + { + private readonly Mock _captchaVerifierMock; + private readonly InsertGuestbookMessageCommandValidator _validator; + + public InsertGuestbookMessageCommandValidatorTests() + { + _captchaVerifierMock = new Mock(); + _validator = new InsertGuestbookMessageCommandValidator(_captchaVerifierMock.Object); + } + + [Fact] + public async Task Validate_WithValidCommand_PassesValidation() + { + // Arrange + var command = new InsertGuestbookMessageCommand("Author", "Message", "valid-captcha"); + + _captchaVerifierMock.Setup(x => x.VerifyAsync("valid-captcha", It.IsAny())).ReturnsAsync(true); + + // Act + var result = await _validator.ValidateAsync(command); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + [Theory] + [InlineData("", "Message", "captcha", "An author is required.")] + [InlineData("Author", "", "captcha", "A message is required.")] + [InlineData("Author", "Message", "", "A captcha response must be sent with the payload.")] + public async Task Validate_WithMissingRequiredFields_ReturnsExpectedErrors(string author, string message, string captcha, string expectedError) + { + // Arrange + var command = new InsertGuestbookMessageCommand(author, message, captcha); + + // Act + var result = await _validator.ValidateAsync(command); + + // Assert + Assert.False(result.IsValid); + Assert.Contains(result.Errors, error => error.ErrorMessage == expectedError); + } + + [Fact] + public async Task Validate_LongAuthor_ReturnsError() + { + // Arrange + var command = new InsertGuestbookMessageCommand(new string('A', 201), "Message", "captcha"); + + // Act + var result = await _validator.ValidateAsync(command); + + // Assert + Assert.False(result.IsValid); + Assert.Contains(result.Errors, error => error.ErrorMessage == "The author field should not exceed 200 characters."); + } + + [Fact] + public async Task Validate_LongMessage_ReturnsError() + { + // Arrange + var command = new InsertGuestbookMessageCommand("Author", new string('A', 4097), "captcha"); + + // Act + var result = await _validator.ValidateAsync(command); + + // Assert + Assert.False(result.IsValid); + Assert.Contains(result.Errors, error => error.ErrorMessage == "The message field should not exceed 4096 characters."); + } + + [Fact] + public async Task Validate_InvalidCaptcha_ReturnsError() + { + // Arrange + var command = new InsertGuestbookMessageCommand("Author", "Message", "invalid-captcha"); + + _captchaVerifierMock.Setup(x => x.VerifyAsync("invalid-captcha", It.IsAny())).ReturnsAsync(false); + + // Act + var result = await _validator.ValidateAsync(command); + + // Assert + Assert.False(result.IsValid); + Assert.Contains(result.Errors, error => error.ErrorMessage == "The captcha challenge response was not accepted."); + } + } + + public class InsertGuestbookMessageCommandExceptionHandlerTests + { + private readonly InsertGuestbookMessageCommandExceptionHandler _handler; + + public InsertGuestbookMessageCommandExceptionHandlerTests() + { + _handler = new InsertGuestbookMessageCommandExceptionHandler(); + } + + [Fact] + public async Task Handle_WithValidationException_SetsErrorResult() + { + // Arrange + var command = new InsertGuestbookMessageCommand("Author", "Message", "captcha"); + var validationFailures = new[] + { + new ValidationFailure("Author", "First error"), + new ValidationFailure("Message", "Second error") + }; + var exception = new ValidationException(validationFailures); + var state = new RequestExceptionHandlerState(); + + // Act + await _handler.Handle(command, exception, state, CancellationToken.None); + + // Assert + Assert.True(state.Response != null); + Assert.False(state.Response.Success); + Assert.Equal("First error Second error", state.Response.ErrorMessage); + } + + [Fact] + public async Task Handle_WithEmptyValidationErrors_SetsEmptyErrorMessage() + { + // Arrange + var command = new InsertGuestbookMessageCommand("Author", "Message", "captcha"); + var exception = new ValidationException(Array.Empty()); + var state = new RequestExceptionHandlerState(); + + // Act + await _handler.Handle(command, exception, state, CancellationToken.None); + + // Assert + Assert.True(state.Response != null); + Assert.False(state.Response.Success); + Assert.Empty(state.Response.ErrorMessage); + } + } +} \ No newline at end of file diff --git a/tests/Guestbooky.UnitTests/Application/UseCases/ListGuestbookMessagesQueryTests.cs b/tests/Guestbooky.UnitTests/Application/UseCases/ListGuestbookMessagesQueryTests.cs new file mode 100644 index 0000000..322b93a --- /dev/null +++ b/tests/Guestbooky.UnitTests/Application/UseCases/ListGuestbookMessagesQueryTests.cs @@ -0,0 +1,98 @@ +using Guestbooky.Application.UseCases.ListGuestbookMessages; +using Guestbooky.Domain.Abstractions.Repositories; +using Guestbooky.Domain.Entities.Message; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.UnitTests.Application.UseCases; + +public class ListGuestbookMessagesQueryHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly ListGuestbookMessagesQueryHandler _handler; + + public ListGuestbookMessagesQueryHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new ListGuestbookMessagesQueryHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_WithMessages_ReturnsList() + { + // Arrange + IEnumerable messages = new List + { + GuestbookMessage.CreateExisting( + Guid.Parse("11111111-1111-1111-1111-111111111111"), + "Asdrubal", + "Hello World", + DateTimeOffset.UnixEpoch.AddDays(1)), + GuestbookMessage.CreateExisting( + Guid.Parse("22222222-2222-2222-2222-222222222222"), + "Godolina", + "Perdi", + DateTimeOffset.UnixEpoch.AddDays(2)) + }; + + _repositoryMock.Setup(x => x.GetAsync(0, It.IsAny())).ReturnsAsync(messages); + + var query = new ListGuestbookMessagesQuery(0); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + Assert.NotNull(result.Messages); + var messagesList = result.Messages.ToList(); + Assert.Equal(2, messagesList.Count); + + Assert.Equal("11111111-1111-1111-1111-111111111111", messagesList[0].Id); + Assert.Equal("Asdrubal", messagesList[0].Author); + Assert.Equal("Hello World", messagesList[0].Message); + Assert.Equal(DateTimeOffset.UnixEpoch.AddDays(1), messagesList[0].Timestamp); + + Assert.Equal("22222222-2222-2222-2222-222222222222", messagesList[1].Id); + Assert.Equal("Godolina", messagesList[1].Author); + Assert.Equal("Perdi", messagesList[1].Message); + Assert.Equal(DateTimeOffset.UnixEpoch.AddDays(2), messagesList[1].Timestamp); + } + + [Fact] + public async Task Handle_NoItems_ReturnsEmpty() + { + // Arrange + _repositoryMock.Setup(x => x.GetAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + + var query = new ListGuestbookMessagesQuery(It.IsAny()); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + Assert.NotNull(result.Messages); + Assert.Empty(result.Messages); + } + + [Theory] + [InlineData(0)] + [InlineData(10)] + [InlineData(100)] + public async Task Handle_WithDifferentOffsets_PassesOffset(long offset) + { + // Arrange + _repositoryMock.Setup(x => x.GetAsync(offset, It.IsAny())).ReturnsAsync(Enumerable.Empty()); + + var query = new ListGuestbookMessagesQuery(offset); + + // Act + await _handler.Handle(query, CancellationToken.None); + + // Assert + _repositoryMock.Verify(x => x.GetAsync(offset, It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/tests/Guestbooky.UnitTests/Application/UseCases/RefreshTokenCommandTests.cs b/tests/Guestbooky.UnitTests/Application/UseCases/RefreshTokenCommandTests.cs new file mode 100644 index 0000000..c7f7fb7 --- /dev/null +++ b/tests/Guestbooky.UnitTests/Application/UseCases/RefreshTokenCommandTests.cs @@ -0,0 +1,80 @@ +using Guestbooky.Application.Interfaces; +using Guestbooky.Application.UseCases.RefreshToken; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using System.Text; +using System.Threading.Tasks; + +namespace Guestbooky.UnitTests.Application.UseCases; + +public class RefreshTokenCommandHandlerTests +{ + private readonly Mock _refreshTokenServiceMock; + private readonly Mock _jwtTokenServiceMock; + private readonly RefreshTokenCommandHandler _handler; + + public RefreshTokenCommandHandlerTests() + { + _refreshTokenServiceMock = new Mock(); + _jwtTokenServiceMock = new Mock(); + _handler = new RefreshTokenCommandHandler(_refreshTokenServiceMock.Object, _jwtTokenServiceMock.Object); + } + + [Fact] + public async Task Handle_WithValidRefreshToken_ReturnsToken() + { + // Arrange + const string oldRefreshToken = "old-refresh-token"; + const string username = "testuser"; + const string newAccessToken = "new-access-token"; + const string newRefreshToken = "new-refresh-token"; + + var claimsPrincipal = new ClaimsPrincipal(new GenericIdentity(username)); + + _refreshTokenServiceMock.Setup(x => x.ValidateRefreshToken(It.IsAny())).Returns(claimsPrincipal); + _jwtTokenServiceMock.Setup(x => x.GenerateToken(It.IsAny())).Returns(newAccessToken); + _refreshTokenServiceMock.Setup(x => x.GenerateRefreshToken()).Returns(newRefreshToken); + + var command = new RefreshTokenCommand(oldRefreshToken); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.Equal(newAccessToken, result.Token); + Assert.Equal(newRefreshToken, result.RefreshToken); + Assert.Null(result.ErrorMessage); + + _refreshTokenServiceMock.Verify(x => x.SaveRefreshToken(username, newRefreshToken), Times.Once); + } + + [Fact] + public async Task Handle_WithInvalidRefreshToken_ReturnsFailure() + { + // Arrange + const string invalidRefreshToken = "invalid-refresh-token"; + + _refreshTokenServiceMock.Setup(x => x.ValidateRefreshToken(It.IsAny())) + .Returns((ClaimsPrincipal)null!); + + var command = new RefreshTokenCommand(invalidRefreshToken); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.False(result.Success); + Assert.Equal(string.Empty, result.Token); + Assert.Equal(string.Empty, result.RefreshToken); + Assert.Equal("Could not validate the cached refresh token.", result.ErrorMessage); + + _jwtTokenServiceMock.Verify(x => x.GenerateToken(It.IsAny()), Times.Never); + _refreshTokenServiceMock.Verify(x => x.GenerateRefreshToken(), Times.Never); + _refreshTokenServiceMock.Verify(x => x.SaveRefreshToken(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/tests/Guestbooky.UnitTests/Guestbooky.UnitTests.csproj b/tests/Guestbooky.UnitTests/Guestbooky.UnitTests.csproj new file mode 100644 index 0000000..e82c1fc --- /dev/null +++ b/tests/Guestbooky.UnitTests/Guestbooky.UnitTests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + true + false + true + cobertura + $(OutputPath)TestResults/coverage/ + AGPL-3.0-or-later + ..\..\..\LICENSE + Felipe Cotti + + + + + + + + + + + + + + + + + + + + + +