├── .github └── dependabot.yml ├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── ap ├── __init__.py ├── commands │ ├── __init__.py │ ├── accept_follower.py │ ├── add.py │ ├── command.py │ ├── create_collection.py │ ├── create_note.py │ ├── delete.py │ ├── follow.py │ ├── followers.py │ ├── following.py │ ├── get.py │ ├── inbox.py │ ├── like.py │ ├── likes.py │ ├── login.py │ ├── logout.py │ ├── outbox.py │ ├── pending_followers.py │ ├── pending_following.py │ ├── reject_follower.py │ ├── remove.py │ ├── replies.py │ ├── share.py │ ├── shares.py │ ├── undo_follow.py │ ├── undo_like.py │ ├── undo_share.py │ ├── update_note.py │ ├── upload.py │ ├── version.py │ └── whoami.py ├── main.py └── version.py ├── docs ├── client.jsonld └── icon-256.png ├── setup.py └── tests ├── test_accept_follower.py ├── test_add.py ├── test_create_collection.py ├── test_create_note.py ├── test_delete.py ├── test_follow.py ├── test_followers.py ├── test_following.py ├── test_get.py ├── test_inbox.py ├── test_like.py ├── test_likes.py ├── test_login.py ├── test_outbox.py ├── test_pending_followers.py ├── test_pending_following.py ├── test_reject_follower.py ├── test_remove.py ├── test_replies.py ├── test_share.py ├── test_shares.py ├── test_undo_follow.py ├── test_undo_like.py ├── test_undo_share.py ├── test_update_note.py ├── test_version.py └── test_whoami.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # VSCode file 163 | .vscode -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests-oauthlib = "*" 8 | tabulate = "*" 9 | webfinger = "*" 10 | toml = "*" 11 | setuptools = "*" 12 | requests = "*" 13 | 14 | [dev-packages] 15 | requests = "==2.31.0" 16 | requests-oauthlib = "==2.0.0" 17 | tabulate = "==0.9.0" 18 | webfinger = "==1.0" 19 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "074fa7752540e48757f017220611e61785e76edea1112d28db1fd465c1cecf89" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "certifi": { 18 | "hashes": [ 19 | "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", 20 | "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" 21 | ], 22 | "markers": "python_version >= '3.6'", 23 | "version": "==2025.1.31" 24 | }, 25 | "charset-normalizer": { 26 | "hashes": [ 27 | "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", 28 | "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa", 29 | "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a", 30 | "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", 31 | "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b", 32 | "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", 33 | "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", 34 | "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", 35 | "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", 36 | "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", 37 | "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", 38 | "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", 39 | "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", 40 | "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", 41 | "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", 42 | "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", 43 | "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", 44 | "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", 45 | "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", 46 | "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", 47 | "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e", 48 | "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a", 49 | "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4", 50 | "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca", 51 | "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", 52 | "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", 53 | "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", 54 | "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", 55 | "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", 56 | "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", 57 | "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", 58 | "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", 59 | "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", 60 | "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", 61 | "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", 62 | "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd", 63 | "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c", 64 | "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", 65 | "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", 66 | "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", 67 | "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", 68 | "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824", 69 | "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", 70 | "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf", 71 | "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487", 72 | "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d", 73 | "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd", 74 | "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", 75 | "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534", 76 | "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", 77 | "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", 78 | "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", 79 | "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd", 80 | "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", 81 | "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9", 82 | "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", 83 | "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", 84 | "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d", 85 | "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", 86 | "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", 87 | "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", 88 | "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", 89 | "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", 90 | "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", 91 | "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8", 92 | "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", 93 | "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", 94 | "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", 95 | "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", 96 | "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", 97 | "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", 98 | "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", 99 | "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", 100 | "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", 101 | "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", 102 | "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", 103 | "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", 104 | "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e", 105 | "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6", 106 | "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", 107 | "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", 108 | "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e", 109 | "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", 110 | "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", 111 | "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c", 112 | "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", 113 | "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", 114 | "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089", 115 | "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", 116 | "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e", 117 | "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", 118 | "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616" 119 | ], 120 | "markers": "python_version >= '3.7'", 121 | "version": "==3.4.1" 122 | }, 123 | "idna": { 124 | "hashes": [ 125 | "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", 126 | "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 127 | ], 128 | "markers": "python_version >= '3.6'", 129 | "version": "==3.10" 130 | }, 131 | "oauthlib": { 132 | "hashes": [ 133 | "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", 134 | "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918" 135 | ], 136 | "markers": "python_version >= '3.6'", 137 | "version": "==3.2.2" 138 | }, 139 | "requests": { 140 | "hashes": [ 141 | "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", 142 | "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 143 | ], 144 | "index": "pypi", 145 | "markers": "python_version >= '3.8'", 146 | "version": "==2.32.3" 147 | }, 148 | "requests-oauthlib": { 149 | "hashes": [ 150 | "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", 151 | "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9" 152 | ], 153 | "index": "pypi", 154 | "markers": "python_version >= '3.4'", 155 | "version": "==2.0.0" 156 | }, 157 | "setuptools": { 158 | "hashes": [ 159 | "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", 160 | "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3" 161 | ], 162 | "index": "pypi", 163 | "markers": "python_version >= '3.9'", 164 | "version": "==75.8.0" 165 | }, 166 | "tabulate": { 167 | "hashes": [ 168 | "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", 169 | "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f" 170 | ], 171 | "index": "pypi", 172 | "markers": "python_version >= '3.7'", 173 | "version": "==0.9.0" 174 | }, 175 | "toml": { 176 | "hashes": [ 177 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 178 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 179 | ], 180 | "index": "pypi", 181 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", 182 | "version": "==0.10.2" 183 | }, 184 | "urllib3": { 185 | "hashes": [ 186 | "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", 187 | "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d" 188 | ], 189 | "markers": "python_version >= '3.9'", 190 | "version": "==2.3.0" 191 | }, 192 | "webfinger": { 193 | "hashes": [ 194 | "sha256:f9b7c8a4a65945aceb092c73f0d6ff25fc40e21977bde4f3e38384ee4aa571ff" 195 | ], 196 | "index": "pypi", 197 | "version": "==1.0" 198 | } 199 | }, 200 | "develop": {} 201 | } 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ap 2 | 3 | [![license](https://img.shields.io/github/license/evanp/ap.svg)](LICENSE) 4 | [![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) 5 | 6 | `ap` is a command-line client for the [ActivityPub](https://www.w3.org/TR/activitypub/) protocol. 7 | 8 | ## Table of Contents 9 | 10 | - [Background](#background) 11 | - [Install](#install) 12 | - [Usage](#usage) 13 | - [Maintainers](#maintainers) 14 | - [Contributing](#contributing) 15 | - [License](#license) 16 | 17 | ## Background 18 | 19 | I initially developed this program to illustrate how to write client code for the ActivityPub API as part of my book for O'Reilly Media, "ActivityPub: Programming for the Social Web". 20 | 21 | ## Install 22 | 23 | I haven't pushed it to PyPI yet, so you'll have to install it from source: 24 | 25 | ```bash 26 | git clone https://github.com/evanp/ap.git 27 | cd ap 28 | python setup.py install 29 | ``` 30 | 31 | ## Usage 32 | 33 | `ap` uses the subcommand pattern common with other large command-line programs like `git` and `docker`. The full list of subcommands is available by typing `ap --help`. Familiarity with the ActivityPub protocol is helpful for understanding these commands! 34 | 35 | ### `ap login ` 36 | 37 | Logs in as a user to an ActivityPub API server using OAuth 2.0. The `` argument is a Webfinger ID or the URL of the user's profile. 38 | 39 | This stores the OAuth 2.0 token(s) in a file in the user's home directory, `$HOME/.ap/token.json`, so that subsequent commands can use them. 40 | 41 | ### `ap logout` 42 | 43 | Logs out of the current session by deleting the token file. 44 | 45 | ### `ap whoami` 46 | 47 | Shows the currently logged-in user. 48 | 49 | ### `ap get ` 50 | 51 | Gets the object with the given ID and prints it to stdout. 52 | 53 | ### `ap inbox` 54 | ### `ap outbox` 55 | ### `ap followers` 56 | ### `ap following` 57 | ### `ap pending followers` 58 | ### `ap pending following` 59 | 60 | Shows these collections for the currently logged-in user. 61 | 62 | ### `ap follow ` 63 | 64 | Follows the user with the given ID. 65 | 66 | ### `ap accept follower ` 67 | ### `ap reject follower ` 68 | 69 | Accepts or rejects a follow request from the user with the given ID. 70 | 71 | ### `ap undo follow ` 72 | 73 | Unfollows the user with the given ID. 74 | 75 | ### `ap create note ` 76 | 77 | Creates a Note object with the given text. 78 | 79 | ### `ap upload ` 80 | 81 | Uploads the given file and prints the resulting URL. 82 | 83 | ## Maintainers 84 | 85 | - [@evanp](https://github.com/evanp) (Evan Prodromou; [@evan@cosocial.ca](https://cosocial.ca/users/evan) on Mastodon) 86 | 87 | ## Contributing 88 | 89 | I'm very interested in contributions to this project. Some quick notes: 90 | 91 | - Please open an issue before starting work on a new feature. This will help us coordinate and make sure that the feature is a good fit for the project. 92 | - Ideally, commands should map closely to the ActivityPub protocol. If you're not sure how to do that, please open an issue and we can discuss it. 93 | - Please make sure that your code passes the existing tests, and add new tests as appropriate. 94 | - Please make your code format correctly with [Python Black](https://black.readthedocs.io/). 95 | 96 | Small note: If editing the Readme, please conform to the [standard-readme](https://github.com/RichardLitt/standard-readme) specification. 97 | 98 | ## License 99 | 100 | [GPL v3 or later](../LICENSE) 101 | -------------------------------------------------------------------------------- /ap/__init__.py: -------------------------------------------------------------------------------- 1 | pass 2 | -------------------------------------------------------------------------------- /ap/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .accept_follower import AcceptFollowerCommand 2 | from .create_collection import CreateCollectionCommand 3 | from .create_note import CreateNoteCommand 4 | from .delete import DeleteCommand 5 | from .follow import FollowCommand 6 | from .followers import FollowersCommand 7 | from .following import FollowingCommand 8 | from .get import GetCommand 9 | from .inbox import InboxCommand 10 | from .login import LoginCommand 11 | from .logout import LogoutCommand 12 | from .outbox import OutboxCommand 13 | from .pending_followers import PendingFollowersCommand 14 | from .pending_following import PendingFollowingCommand 15 | from .reject_follower import RejectFollowerCommand 16 | from .undo_follow import UndoFollowCommand 17 | from .upload import UploadCommand 18 | from .whoami import WhoamiCommand 19 | from .update_note import UpdateNoteCommand 20 | from .add import AddCommand 21 | from .remove import RemoveCommand 22 | from .likes import LikesCommand 23 | from .version import VersionCommand 24 | from .like import LikeCommand 25 | from .undo_like import UndoLikeCommand 26 | from .shares import SharesCommand 27 | from .share import ShareCommand 28 | from .undo_share import UndoShareCommand 29 | from .replies import RepliesCommand 30 | 31 | __all__ = [ 32 | "AcceptFollowerCommand", 33 | "AddCommand", 34 | "CreateCollectionCommand", 35 | "CreateNoteCommand", 36 | "DeleteCommand", 37 | "FollowCommand", 38 | "FollowersCommand", 39 | "FollowingCommand", 40 | "GetCommand", 41 | "InboxCommand", 42 | "LikesCommand", 43 | "LoginCommand", 44 | "LogoutCommand", 45 | "OutboxCommand", 46 | "PendingFollowersCommand", 47 | "PendingFollowingCommand", 48 | "RejectFollowerCommand", 49 | "RemoveCommand", 50 | "UndoFollowCommand", 51 | "UpdateNoteCommand", 52 | "UploadCommand", 53 | "WhoamiCommand", 54 | "VersionCommand", 55 | "LikeCommand", 56 | "UndoLikeCommand", 57 | "SharesCommand", 58 | "ShareCommand", 59 | "UndoShareCommand", 60 | "RepliesCommand" 61 | ] 62 | -------------------------------------------------------------------------------- /ap/commands/accept_follower.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | 4 | class AcceptFollowerCommand(Command): 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | self.id = args.id 8 | 9 | def run(self): 10 | actor_id = self.get_actor_id(self.id) 11 | 12 | if actor_id is None: 13 | print("No actor found for %s" % self.id) 14 | return 15 | 16 | coll = self.get_actor_collection("pendingFollowers") 17 | 18 | found = None 19 | 20 | for item in self.items(coll): 21 | activity = self.to_object(item, ["actor", "type"]) 22 | if "actor" not in activity: 23 | continue 24 | if actor_id == self.to_id(activity["actor"]): 25 | found = activity 26 | break 27 | 28 | if found is None: 29 | print("No pending follower found for %s" % self.id) 30 | return 31 | 32 | act = { 33 | "type": "Accept", 34 | "object": {"type": found["type"], "actor": actor_id, "id": found["id"]}, 35 | "to": [actor_id], 36 | } 37 | 38 | self.do_activity(act) 39 | 40 | print("Accepted follower %s" % self.id) 41 | -------------------------------------------------------------------------------- /ap/commands/add.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | class AddCommand(Command): 4 | """Add an object to a collection""" 5 | 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | self.target = args.target 9 | self.id = args.id 10 | 11 | def run(self): 12 | """Run the command""" 13 | 14 | if type(self.id) == list and len(self.id) == 1: 15 | obj = self.id[0] 16 | else: 17 | obj = self.id[0] 18 | 19 | result = self.do_activity({ 20 | "@context": "https://www.w3.org/ns/activitystreams", 21 | "type": "Add", 22 | "target": self.target, 23 | "object": obj 24 | }) 25 | 26 | print(result["id"]) -------------------------------------------------------------------------------- /ap/commands/create_collection.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import json 3 | 4 | class CreateCollectionCommand(Command): 5 | """Create a new collection""" 6 | 7 | def __init__(self, args, env): 8 | super().__init__(args, env) 9 | self.name = args.name 10 | self.public = args.public 11 | self.followers_only = args.followers_only 12 | self.private = args.private 13 | self.to = args.to 14 | self.cc = args.cc 15 | 16 | def run(self): 17 | """Execute the command""" 18 | 19 | language_code = self.get_language_code() 20 | if language_code is None: 21 | language_code = "unk" 22 | 23 | act = { 24 | "to": [], 25 | "cc": [], 26 | "type": "Create", 27 | "object": { 28 | "type": "Collection", 29 | "nameMap": { 30 | language_code: self.name, 31 | } 32 | }, 33 | } 34 | 35 | if self.public: 36 | act["to"].append("https://www.w3.org/ns/activitystreams#Public") 37 | elif self.followers_only: 38 | actor = self.logged_in_actor() 39 | if actor is None: 40 | raise Exception("Not logged in") 41 | followers = actor.get("followers", None) 42 | if followers is None: 43 | raise Exception("No followers collection found") 44 | followers_id = self.to_id(followers) 45 | act["to"].append(followers_id) 46 | elif self.private: 47 | pass 48 | 49 | if self.to: 50 | for to in self.to: 51 | act["to"].append(self.get_actor_id(to)) 52 | if self.cc: 53 | for cc in self.cc: 54 | act["cc"].append(self.get_actor_id(cc)) 55 | 56 | result = self.do_activity(act) 57 | 58 | print(json.dumps(result, indent=4)) -------------------------------------------------------------------------------- /ap/commands/create_note.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import json 3 | import html 4 | import logging 5 | 6 | class CreateNoteCommand(Command): 7 | def __init__(self, args, env): 8 | super().__init__(args, env) 9 | self.source = " ".join(args.source) 10 | self.public = args.public 11 | self.followers_only = args.followers_only 12 | self.private = args.private 13 | self.to = args.to 14 | self.cc = args.cc 15 | self.in_reply_to = args.in_reply_to 16 | 17 | def run(self): 18 | html, tags = self.transform_microsyntax(self.source) 19 | obj = { 20 | "type": "Note", 21 | "source": {"mediaType": "text/plain", "content": self.source}, 22 | "tag": tags, 23 | "content": html, 24 | } 25 | if self.in_reply_to: 26 | obj["inReplyTo"] = self.in_reply_to 27 | 28 | act = { 29 | "to": [], 30 | "cc": [], 31 | "type": "Create", 32 | "object": obj 33 | } 34 | 35 | if self.public: 36 | act["to"].append("https://www.w3.org/ns/activitystreams#Public") 37 | elif self.followers_only: 38 | actor = self.logged_in_actor() 39 | if actor is None: 40 | raise Exception("Not logged in") 41 | followers = actor.get("followers", None) 42 | if followers is None: 43 | raise Exception("No followers collection found") 44 | followers_id = self.to_id(followers) 45 | act["to"].append(followers_id) 46 | elif self.private: 47 | pass 48 | 49 | if self.to: 50 | for to in self.to: 51 | act["to"].append(self.get_actor_id(to)) 52 | if self.cc: 53 | for cc in self.cc: 54 | act["cc"].append(self.get_actor_id(cc)) 55 | 56 | result = self.do_activity(act) 57 | 58 | print(json.dumps(result, indent=4)) 59 | -------------------------------------------------------------------------------- /ap/commands/delete.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | 4 | class DeleteCommand(Command): 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | self.id = args.id 8 | self.force = args.force 9 | 10 | def run(self): 11 | """Delete an object 12 | 13 | Args: 14 | id (str): The ID of the object to delete 15 | """ 16 | 17 | obj = self.get_object(self.id) 18 | 19 | if obj is None: 20 | raise Exception(f"Object {self.id} not found") 21 | 22 | name = self.to_text(obj) 23 | 24 | if name is None: 25 | name = f'a {obj["type"]} object' 26 | 27 | if not self.force: 28 | prompt = f"Are you sure you want to delete {name} ({self.id}) [y/N]?" 29 | confirmation = input(prompt).lower() 30 | if confirmation != "y": 31 | return 32 | 33 | result = self.do_activity( 34 | { 35 | "@context": "https://www.w3.org/ns/activitystreams", 36 | "type": "Delete", 37 | "object": self.id, 38 | } 39 | ) 40 | 41 | print("Deleted.") 42 | -------------------------------------------------------------------------------- /ap/commands/follow.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import json 3 | 4 | 5 | class FollowCommand(Command): 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | self.id = args.id 9 | 10 | def run(self): 11 | actor_id = self.get_actor_id(self.id) 12 | if actor_id is None: 13 | raise Exception("Actor not found") 14 | 15 | act = {"type": "Follow", "object": actor_id, "to": [actor_id]} 16 | 17 | result = self.do_activity(act) 18 | 19 | print(json.dumps(result, indent=4)) 20 | -------------------------------------------------------------------------------- /ap/commands/followers.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import itertools 3 | from tabulate import tabulate 4 | 5 | 6 | class FollowersCommand(Command): 7 | def __init__(self, args, env): 8 | super().__init__(args, env) 9 | self.offset = args.offset 10 | self.limit = args.limit 11 | 12 | def run(self): 13 | coll = self.get_actor_collection("followers") 14 | slice = self.collection_slice(coll, self.offset, self.limit) 15 | rows = [] 16 | for item in slice: 17 | follower = self.to_object( 18 | item, 19 | [ 20 | "id", 21 | "preferredUsername", 22 | ["name", "nameMap", "summary", "summaryMap"], 23 | ], 24 | ) 25 | id = self.to_webfinger(follower) 26 | name = self.to_text(follower) 27 | rows.append([id, name]) 28 | print(tabulate(rows, headers=["id", "name"])) 29 | -------------------------------------------------------------------------------- /ap/commands/following.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import itertools 3 | from tabulate import tabulate 4 | 5 | 6 | class FollowingCommand(Command): 7 | def __init__(self, args, env): 8 | super().__init__(args, env) 9 | self.offset = args.offset 10 | self.limit = args.limit 11 | 12 | def run(self): 13 | coll = self.get_actor_collection("following") 14 | slice = self.collection_slice(coll, self.offset, self.limit) 15 | rows = [] 16 | for item in slice: 17 | follower = self.to_object( 18 | item, 19 | [ 20 | "id", 21 | "preferredUsername", 22 | ["name", "nameMap", "summary", "summaryMap"], 23 | ], 24 | ) 25 | id = self.to_webfinger(follower) 26 | name = self.to_text(follower) 27 | rows.append([id, name]) 28 | print(tabulate(rows, headers=["id", "name"])) 29 | -------------------------------------------------------------------------------- /ap/commands/get.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import json 3 | 4 | 5 | class GetCommand(Command): 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | self.id = args.id 9 | 10 | def run(self): 11 | if (not self.is_activitypub_id(self.id)): 12 | if (self.is_webfinger_id(self.id)): 13 | self.id = self.to_activitypub_id(self.id) 14 | else: 15 | raise Exception(f"Invalid id: {self.id}") 16 | data = self.get_object(self.id) 17 | print(json.dumps(data, indent=4)) 18 | -------------------------------------------------------------------------------- /ap/commands/inbox.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import itertools 3 | from tabulate import tabulate 4 | from requests.exceptions import HTTPError 5 | 6 | class InboxCommand(Command): 7 | def __init__(self, args, env): 8 | super().__init__(args, env) 9 | self.offset = args.offset 10 | self.limit = args.limit 11 | 12 | def run(self): 13 | actor = self.logged_in_actor() 14 | if actor is None: 15 | raise Exception("Not logged in") 16 | inbox = actor.get("inbox", None) 17 | if inbox is None: 18 | raise Exception("No inbox found") 19 | inbox_id = self.to_id(inbox) 20 | slice = itertools.islice( 21 | self.items(inbox_id), self.offset, self.offset + self.limit 22 | ) 23 | rows = [] 24 | for item in slice: 25 | # Use the object as provided as fallback 26 | activity = self.to_object(item, [["actor", "attributedTo"], "type", "summary", "published", "id"]) 27 | id = activity.get("id", None) 28 | type = activity.get("type", None) 29 | summary = self.text_prop(activity, "summary") 30 | published = activity.get("published", None) 31 | # Use the actor id as fallback 32 | actor_prop = activity.get("actor", activity.get("attributedTo", None)) 33 | if actor_prop is None: 34 | actor = "" 35 | else: 36 | actor = self.to_webfinger(actor_prop) 37 | rows.append([id, actor, type, summary, published]) 38 | print(tabulate(rows, headers=["id", "actor", "type", "summary", "published"])) 39 | -------------------------------------------------------------------------------- /ap/commands/like.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | class LikeCommand(Command): 4 | """Like an object""" 5 | 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | self.id = args.id 9 | 10 | def run(self): 11 | result = self.do_activity({ 12 | "type": "Like", 13 | "object": self.id, 14 | }) 15 | print(result["id"]) 16 | -------------------------------------------------------------------------------- /ap/commands/likes.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from tabulate import tabulate 3 | 4 | class LikesCommand(Command): 5 | """`likes` command""" 6 | 7 | def __init__(self, args, env): 8 | super().__init__(args, env) 9 | self.id = args.id 10 | self.limit = args.limit 11 | self.offset = args.offset 12 | 13 | def run(self): 14 | """Run the command""" 15 | 16 | obj = self.get_object(self.id) 17 | if obj is None: 18 | raise ValueError("Object not found") 19 | 20 | if "likes" not in obj: 21 | raise ValueError("Object has no likes") 22 | 23 | likes_id = self.to_id(obj["likes"]) 24 | 25 | likes_slice = self.collection_slice(likes_id, self.offset, self.limit) 26 | 27 | rows = [] 28 | 29 | for item in likes_slice: 30 | id = self.to_id(item) 31 | activity = self.to_object(item, ["actor", "published"]) 32 | actor = self.to_webfinger(activity["actor"]) 33 | published = activity.get("published") 34 | rows.append([id, actor, published]) 35 | 36 | print(tabulate(rows, headers=["id", "actor", "published"])) 37 | -------------------------------------------------------------------------------- /ap/commands/login.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import webbrowser 3 | import os 4 | import base64 5 | import hashlib 6 | from pathlib import Path 7 | from requests_oauthlib import OAuth2Session 8 | from http.server import BaseHTTPRequestHandler, HTTPServer 9 | import json 10 | import urllib 11 | import logging 12 | 13 | CLIENT_ID = "https://evanp.github.io/ap/client.jsonld" 14 | REDIRECT_URI = "http://localhost:63546/callback" 15 | SCOPE = "read write" 16 | 17 | class LoginRedirectHandler(BaseHTTPRequestHandler): 18 | command = None 19 | 20 | def do_GET(self): 21 | global oauth, token_endpoint, state, verifier, code 22 | if self.path.startswith("/callback"): 23 | self.send_response(200) 24 | self.send_header("Content-type", "text/html") 25 | self.end_headers() 26 | self.wfile.write( 27 | b"Success

You may now close this window.

" 28 | ) 29 | LoginRedirectHandler.command.on_callback( 30 | urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) 31 | ) 32 | 33 | def log_request(self, code="-", size="-"): 34 | pass 35 | 36 | 37 | class LoginCommand(Command): 38 | def __init__(self, args, env): 39 | super().__init__(args, env) 40 | self.id = args.id 41 | 42 | def pkce(self): 43 | """Generate a PKCE code verifier and challenge 44 | 45 | Returns: 46 | tuple: A tuple containing the code verifier and challenge 47 | """ 48 | verifier = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").rstrip("=") 49 | challenge = hashlib.sha256(verifier.encode("utf-8")).digest() 50 | challenge = base64.urlsafe_b64encode(challenge).decode("utf-8").rstrip("=") 51 | return (verifier, challenge) 52 | 53 | def oauth_endpoints(self, json): 54 | auth_endpoint = self.get_endpoint(json, "oauthAuthorizationEndpoint") 55 | token_endpoint = self.get_endpoint(json, "oauthTokenEndpoint") 56 | return (auth_endpoint, token_endpoint) 57 | 58 | def save_token(self, token): 59 | apdir = Path(self.env.get("HOME")) / ".ap" 60 | if not apdir.exists(): 61 | apdir.mkdir(700) 62 | data = {"actor_id": self.actor_id, **token} 63 | with open(apdir / "token.json", "w") as f: 64 | f.write(json.dumps(data)) 65 | 66 | def run(self): 67 | """Log into an ActivityPub server 68 | 69 | Args: 70 | id (str): The ID of the user to login as; either an 71 | ActivityPub ID or a webfinger address 72 | """ 73 | 74 | self.actor_id = self.get_actor_id(self.id) 75 | json = self.get_public(self.actor_id) 76 | 77 | (auth_endpoint, token_endpoint) = self.oauth_endpoints(json) 78 | 79 | self.token_endpoint = token_endpoint 80 | 81 | (verifier, challenge) = self.pkce() 82 | 83 | self.verifier = verifier 84 | 85 | self.oauth = OAuth2Session(CLIENT_ID, redirect_uri=REDIRECT_URI, scope=SCOPE) 86 | authorization_url, state = self.oauth.authorization_url( 87 | auth_endpoint, code_challenge=challenge, code_challenge_method="S256" 88 | ) 89 | 90 | self.state = state 91 | 92 | webbrowser.open(authorization_url) 93 | 94 | # Processing continues in on_callback() 95 | 96 | LoginRedirectHandler.command = self 97 | 98 | server = HTTPServer(("localhost", 63546), LoginRedirectHandler) 99 | server.handle_request() # To handle only the first request 100 | server.server_close() 101 | 102 | def on_callback(self, qs): 103 | state = qs.get("state", None) 104 | if state is None or self.state != state[0]: 105 | raise Exception("State mismatch") 106 | error = qs.get("error", None) 107 | if error is not None: 108 | error_val = error[0] 109 | if error_val == "access_denied": 110 | print("Access denied") 111 | else: 112 | print(f"Error: {error_val}") 113 | return 114 | codes = qs.get("code", None) 115 | if codes is None: 116 | raise Exception("No code found") 117 | code = codes[0] 118 | token = self.oauth.fetch_token( 119 | self.token_endpoint, 120 | code=code, 121 | code_verifier=self.verifier, 122 | include_client_id=True, 123 | ) 124 | self.save_token(token) 125 | -------------------------------------------------------------------------------- /ap/commands/logout.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | 4 | class LogoutCommand(Command): 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | 8 | def run(self): 9 | token_file = self.token_file() 10 | 11 | if not token_file.exists(): 12 | print("Not logged in") 13 | return 14 | 15 | token_file.unlink() 16 | print("Logged out") 17 | -------------------------------------------------------------------------------- /ap/commands/outbox.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import itertools 3 | from tabulate import tabulate 4 | 5 | 6 | class OutboxCommand(Command): 7 | def __init__(self, args, env): 8 | super().__init__(args, env) 9 | self.offset = args.offset 10 | self.limit = args.limit 11 | 12 | def run(self): 13 | actor = self.logged_in_actor() 14 | if actor is None: 15 | raise Exception("Not logged in") 16 | outbox = actor.get("outbox", None) 17 | if outbox is None: 18 | raise Exception("No outbox found") 19 | outbox_id = self.to_id(outbox) 20 | slice = itertools.islice( 21 | self.items(outbox_id), self.offset, self.offset + self.limit 22 | ) 23 | rows = [] 24 | for item in slice: 25 | id = self.to_id(item) 26 | type = item.get("type", None) 27 | summary = self.text_prop(item, "summary") 28 | published = item.get("published") 29 | rows.append([id, type, summary, published]) 30 | print(tabulate(rows, headers=["id", "type", "summary", "published"])) 31 | -------------------------------------------------------------------------------- /ap/commands/pending_followers.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from tabulate import tabulate 3 | 4 | 5 | class PendingFollowersCommand(Command): 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | self.offset = args.offset 9 | self.limit = args.limit 10 | 11 | def run(self): 12 | coll = self.get_actor_collection("pendingFollowers") 13 | slice = self.collection_slice(coll, self.offset, self.limit) 14 | rows = [] 15 | for item in slice: 16 | activity = self.to_object(item, ["id", "actor", "published"]) 17 | follower = self.to_object( 18 | activity["actor"], 19 | [ 20 | "id", 21 | "preferredUsername", 22 | ["name", "nameMap", "summary", "summaryMap"], 23 | ], 24 | ) 25 | activity_id = self.to_id(activity) 26 | id = self.to_webfinger(follower) 27 | name = self.to_text(follower) 28 | published = activity["published"] if "published" in activity else None 29 | rows.append([activity_id, id, name, published]) 30 | print(tabulate(rows, headers=["activity", "id", "name", "published"])) 31 | -------------------------------------------------------------------------------- /ap/commands/pending_following.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from tabulate import tabulate 3 | 4 | 5 | class PendingFollowingCommand(Command): 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | self.offset = args.offset 9 | self.limit = args.limit 10 | 11 | def run(self): 12 | coll = self.get_actor_collection("pendingFollowing") 13 | slice = self.collection_slice(coll, self.offset, self.limit) 14 | rows = [] 15 | for item in slice: 16 | activity = self.to_object(item, ["id", "object", "published"]) 17 | followed = self.to_object( 18 | activity["object"], 19 | [ 20 | "id", 21 | "preferredUsername", 22 | ["name", "nameMap", "summary", "summaryMap"], 23 | ], 24 | ) 25 | activity_id = self.to_id(activity) 26 | id = self.to_webfinger(followed) 27 | name = self.to_text(followed) 28 | published = activity["published"] if "published" in activity else None 29 | rows.append([activity_id, id, name, published]) 30 | print(tabulate(rows, headers=["activity", "id", "name", "published"])) 31 | -------------------------------------------------------------------------------- /ap/commands/reject_follower.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | 4 | class RejectFollowerCommand(Command): 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | self.id = args.id 8 | 9 | def run(self): 10 | actor_id = self.get_actor_id(self.id) 11 | 12 | if actor_id is None: 13 | print("No actor found for %s" % self.id) 14 | return 15 | 16 | coll = self.get_actor_collection("pendingFollowers") 17 | 18 | found = None 19 | 20 | for item in self.items(coll): 21 | activity = self.to_object(item, ["actor", "type"]) 22 | if "actor" not in activity: 23 | continue 24 | if actor_id == self.to_id(activity["actor"]): 25 | found = activity 26 | break 27 | 28 | if found is None: 29 | print("No pending follower found for %s" % self.id) 30 | return 31 | 32 | act = { 33 | "type": "Reject", 34 | "object": {"type": found["type"], "actor": actor_id, "id": found["id"]}, 35 | "to": [actor_id], 36 | } 37 | 38 | self.do_activity(act) 39 | 40 | print("Rejected follower %s" % self.id) 41 | -------------------------------------------------------------------------------- /ap/commands/remove.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | class RemoveCommand(Command): 4 | """Remove an object from a collection.""" 5 | 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | self.target = args.target 9 | self.id = args.id 10 | 11 | def run(self): 12 | """Run the command.""" 13 | 14 | if type(self.id) == list and len(self.id) == 1: 15 | obj = self.id[0] 16 | else: 17 | obj = self.id[0] 18 | 19 | result = self.do_activity({ 20 | "@context": "https://www.w3.org/ns/activitystreams", 21 | "type": "Remove", 22 | "target": self.target, 23 | "object": obj 24 | }) 25 | 26 | print(result["id"]) -------------------------------------------------------------------------------- /ap/commands/replies.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from tabulate import tabulate 3 | import json 4 | 5 | class RepliesCommand(Command): 6 | """`replies` command""" 7 | 8 | def __init__(self, args, env): 9 | super().__init__(args, env) 10 | self.id = args.id 11 | self.limit = args.limit 12 | self.offset = args.offset 13 | 14 | def run(self): 15 | """Run the command""" 16 | 17 | obj = self.get_object(self.id) 18 | if obj is None: 19 | raise ValueError("Object not found") 20 | 21 | if "replies" not in obj: 22 | raise ValueError("Object has no replies") 23 | 24 | replies_id = self.to_id(obj["replies"]) 25 | 26 | replies_slice = self.collection_slice(replies_id, self.offset, self.limit) 27 | 28 | rows = [] 29 | 30 | for item in replies_slice: 31 | id = self.to_id(item) 32 | reply = self.to_object(item, 33 | ["attributedTo", 34 | "published", 35 | ["content", "contentMap"]]) 36 | if "contentMap" not in reply: 37 | raise Exception(f'No contentMap in {id}') 38 | attributedTo = self.to_webfinger(reply["attributedTo"]) 39 | published = reply.get("published") 40 | content = self.text_prop(reply, "content") 41 | if content is None: 42 | raise Exception(f'No content in {id}') 43 | rows.append([id, attributedTo, content, published]) 44 | 45 | print(tabulate(rows, headers=["id", "attributedTo", "content", "published"])) 46 | -------------------------------------------------------------------------------- /ap/commands/share.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | class ShareCommand(Command): 4 | 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | self.id = args.id 8 | 9 | def run(self): 10 | 11 | result = self.do_activity({ 12 | "type": "Announce", 13 | "object": self.id 14 | }) 15 | 16 | print(f'Share activity: {result["id"]}') -------------------------------------------------------------------------------- /ap/commands/shares.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from tabulate import tabulate 3 | 4 | class SharesCommand(Command): 5 | 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | self.id = args.id 9 | self.offset = args.offset 10 | self.limit = args.limit 11 | 12 | def run(self): 13 | 14 | obj = self.get_object(self.id) 15 | if obj is None: 16 | raise ValueError("Object not found") 17 | 18 | if "shares" not in obj: 19 | raise ValueError("Object has no shares") 20 | 21 | shares_id = self.to_id(obj["shares"]) 22 | 23 | shares_slice = self.collection_slice(shares_id, self.offset, self.limit) 24 | 25 | rows = [] 26 | 27 | for item in shares_slice: 28 | id = self.to_id(item) 29 | activity = self.to_object(item, ["actor", "published"]) 30 | actor = self.to_webfinger(activity["actor"]) 31 | published = activity.get("published") 32 | rows.append([id, actor, published]) 33 | 34 | print(tabulate(rows, headers=["id", "actor", "published"])) 35 | -------------------------------------------------------------------------------- /ap/commands/undo_follow.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | 4 | class UndoFollowCommand(Command): 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | self.id = args.id 8 | 9 | def run(self): 10 | actor_id = self.get_actor_id(self.id) 11 | 12 | if actor_id is None: 13 | print("No actor found for %s" % self.id) 14 | return 15 | 16 | # Try pending first (we might get lucky) 17 | 18 | coll = self.get_actor_collection("pendingFollowing") 19 | 20 | found = None 21 | 22 | for item in self.items(coll): 23 | activity = self.to_object(item, ["object", "type"]) 24 | if actor_id == self.to_id(activity["object"]): 25 | found = activity 26 | break 27 | 28 | if found is None: 29 | coll = self.get_actor_collection("outbox") 30 | 31 | found = None 32 | 33 | for item in self.items(coll): 34 | activity = self.to_object(item, ["object", "type"]) 35 | if "type" not in activity: 36 | continue 37 | if "object" not in activity: 38 | continue 39 | type = activity["type"] 40 | if isinstance(type, list): 41 | if "Follow" not in type: 42 | continue 43 | elif type != "Follow": 44 | continue 45 | if actor_id == self.to_id(activity["object"]): 46 | found = activity 47 | break 48 | 49 | if found is None: 50 | print("No follow found for %s" % self.id) 51 | return 52 | 53 | act = { 54 | "type": "Undo", 55 | "object": {"type": found["type"], "object": actor_id, "id": found["id"]}, 56 | "to": [actor_id], 57 | } 58 | 59 | self.do_activity(act) 60 | 61 | print("Unfollowed %s" % self.id) 62 | -------------------------------------------------------------------------------- /ap/commands/undo_like.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | class UndoLikeCommand(Command): 4 | 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | self.id = args.id 8 | 9 | def run(self): 10 | 11 | actor = self.logged_in_actor() 12 | 13 | actor_id = self.to_id(actor) 14 | 15 | if "liked" not in actor: 16 | raise Exception(f"Actor {actor_id} does not have liked collection") 17 | 18 | found = False 19 | 20 | for item in self.items(actor["liked"]): 21 | item_id = self.to_id(item) 22 | if item_id == self.id: 23 | found = True 24 | break 25 | 26 | if not found: 27 | raise Exception(f"Actor {actor_id} has not liked object {self.id}") 28 | 29 | obj = self.get_object(self.id) 30 | 31 | if (obj is None): 32 | raise Exception(f"Could not find {self.id} object") 33 | 34 | if ("likes" not in obj): 35 | raise Exception(f"{self.id} object does not have likes collection") 36 | 37 | likes = self.to_id(obj["likes"]) 38 | 39 | activity = None 40 | 41 | for item in self.items(likes): 42 | like = self.to_object(item, ["actor"]) 43 | if self.to_id(like["actor"]) == actor_id: 44 | activity = like 45 | break 46 | 47 | if (activity is None): 48 | raise Exception(f"Could not find like for {self.id} object") 49 | 50 | result = self.do_activity({ 51 | "@context": "https://www.w3.org/ns/activitystreams", 52 | "type": "Undo", 53 | "object": activity 54 | }) 55 | 56 | print(f'Undid like activity {activity["id"]} of object {self.id} with result {result["id"]}') -------------------------------------------------------------------------------- /ap/commands/undo_share.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | class UndoShareCommand(Command): 4 | 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | self.id = args.id 8 | 9 | def run(self): 10 | 11 | actor = self.logged_in_actor() 12 | 13 | actor_id = self.to_id(actor) 14 | 15 | obj = self.get_object(self.id) 16 | 17 | if (obj is None): 18 | raise Exception(f"Could not find {self.id} object") 19 | 20 | if ("shares" not in obj): 21 | raise Exception(f"{self.id} object does not have shares collection") 22 | 23 | shares = self.to_id(obj["shares"]) 24 | 25 | activity = None 26 | 27 | for item in self.items(shares): 28 | share = self.to_object(item, ["actor"]) 29 | if self.to_id(share["actor"]) == actor_id: 30 | activity = share 31 | break 32 | 33 | if (activity is None): 34 | raise Exception(f"Could not find share for {self.id} object") 35 | 36 | result = self.do_activity({ 37 | "@context": "https://www.w3.org/ns/activitystreams", 38 | "type": "Undo", 39 | "object": activity 40 | }) 41 | 42 | print(f'Undid share activity {activity["id"]} of object {self.id} with result {result["id"]}') -------------------------------------------------------------------------------- /ap/commands/update_note.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | import json 3 | 4 | class UpdateNoteCommand(Command): 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | self.id = args.id 8 | self.content = args.content 9 | 10 | def run(self): 11 | result = self.do_activity({ 12 | "type": "Update", 13 | "object": { 14 | "type": "Note", 15 | "id": self.id, 16 | "content": self.content, 17 | } 18 | }) 19 | print(json.dumps(result, indent=4)) -------------------------------------------------------------------------------- /ap/commands/upload.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from pathlib import Path 3 | import mimetypes 4 | import json 5 | 6 | 7 | class UploadCommand(Command): 8 | def __init__(self, args, env): 9 | super().__init__(args, env) 10 | self.filename = args.filename 11 | self.title = args.title 12 | self.description = args.description 13 | self.public = args.public 14 | self.followers_only = args.followers_only 15 | self.private = args.private 16 | self.to = args.to 17 | self.cc = args.cc 18 | 19 | def run(self): 20 | actor = self.logged_in_actor() 21 | if actor is None: 22 | raise Exception("Not logged in") 23 | if "endpoints" not in actor: 24 | raise Exception("No endpoints found") 25 | if "uploadMedia" not in actor["endpoints"]: 26 | raise Exception("No uploadMedia endpoint found") 27 | uploadMedia = actor["endpoints"]["uploadMedia"] 28 | path = Path(self.filename) 29 | if not path.exists(): 30 | raise Exception("File not found: %s" % self.filename) 31 | if not path.is_file(): 32 | raise Exception("Not a file: %s" % self.filename) 33 | type, encoding = mimetypes.guess_type(path) 34 | if type is None: 35 | raise Exception("Could not determine file type") 36 | major = type.split("/")[0] 37 | 38 | obj = {"type": self.ap_type(major)} 39 | 40 | lc = self.get_language_code() 41 | 42 | if self.title: 43 | obj["nameMap"] = {} 44 | obj["nameMap"][lc] = self.title 45 | 46 | if self.description: 47 | obj["summaryMap"] = {} 48 | obj["summaryMap"][lc] = self.description 49 | 50 | if self.public: 51 | obj["to"] = ["https://www.w3.org/ns/activitystreams#Public"] 52 | elif self.followers_only: 53 | if "followers" not in actor: 54 | raise Exception("No followers collection found") 55 | followers = actor["followers"] 56 | followers_id = self.to_id(followers) 57 | obj["to"] = [followers_id] 58 | 59 | if self.to: 60 | for to in self.to: 61 | if "to" not in obj: 62 | obj["to"] = [] 63 | obj["to"].append(self.get_actor_id(to)) 64 | if self.cc: 65 | for cc in self.cc: 66 | if "cc" not in obj: 67 | obj["cc"] = [] 68 | obj["cc"].append(self.get_actor_id(cc)) 69 | 70 | file_data = (path.name, path.open("rb"), type) 71 | 72 | object_data = json.dumps(obj) 73 | 74 | multipart_form_data = { 75 | "file": file_data, 76 | "object": ( 77 | "descriptor.jsonld", 78 | object_data, 79 | 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 80 | ), 81 | } 82 | 83 | oauth = self.session() 84 | res = oauth.post(uploadMedia, files=multipart_form_data) 85 | if res.status_code != 201: 86 | raise Exception("Upload failed: %s" % res.text) 87 | result = res.json() 88 | print(result) 89 | 90 | def ap_type(self, major): 91 | if major == "image": 92 | return "Image" 93 | elif major == "video": 94 | return "Video" 95 | elif major == "audio": 96 | return "Audio" 97 | elif major == "text": 98 | return "Article" 99 | else: 100 | return "Document" 101 | -------------------------------------------------------------------------------- /ap/commands/version.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from ..version import __version__ 3 | 4 | class VersionCommand(Command): 5 | 6 | def __init__(self, args, env): 7 | super().__init__(args, env) 8 | 9 | def run(self): 10 | print(f"ap {__version__}") 11 | -------------------------------------------------------------------------------- /ap/commands/whoami.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | 3 | 4 | class WhoamiCommand(Command): 5 | def __init__(self, args, env): 6 | super().__init__(args, env) 7 | 8 | def run(self): 9 | actor = self.logged_in_actor() 10 | if actor is None: 11 | print("Not logged in") 12 | return 13 | 14 | print(self.to_webfinger(actor)) 15 | -------------------------------------------------------------------------------- /ap/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """This is the main entrypoint for the ap ActivityPub command 4 | line client. 5 | 6 | Functions: 7 | main(): Parses arguments and calls corresponding commands 8 | """ 9 | 10 | import sys 11 | import os 12 | import argparse 13 | import ap.commands as commands 14 | 15 | # Create the top-level parser 16 | 17 | def make_parser(): 18 | """Create the top-level parser""" 19 | 20 | parser = argparse.ArgumentParser(description="ActivityPub command line client") 21 | subparsers = parser.add_subparsers(dest="subcommand") 22 | 23 | login_parser = subparsers.add_parser("login", help="Log into an ActivityPub server") 24 | login_parser.add_argument("id", help="Webfinger or ActivityPub ID") 25 | 26 | subparsers.add_parser("logout", help="Log out of the current session") 27 | 28 | subparsers.add_parser("whoami", help="Show the current user") 29 | 30 | get_parser = subparsers.add_parser("get", help="Get an object by ID") 31 | get_parser.add_argument("id", help="id of object to get") 32 | 33 | inbox_parser = subparsers.add_parser("inbox", help="Get inbox") 34 | inbox_parser.add_argument( 35 | "--offset", help="Offset to start at", default=0, type=int 36 | ) 37 | inbox_parser.add_argument("--limit", help="Max items to get", default=10, type=int) 38 | 39 | outbox_parser = subparsers.add_parser("outbox", help="Get outbox") 40 | outbox_parser.add_argument( 41 | "--offset", help="Offset to start at", default=0, type=int 42 | ) 43 | outbox_parser.add_argument("--limit", help="Max items to get", default=10, type=int) 44 | 45 | followers_parser = subparsers.add_parser("followers", help="Get followers") 46 | followers_parser.add_argument( 47 | "--offset", help="Offset to start at", default=0, type=int 48 | ) 49 | followers_parser.add_argument( 50 | "--limit", help="Max items to get", default=10, type=int 51 | ) 52 | 53 | following_parser = subparsers.add_parser("following", help="Get following") 54 | following_parser.add_argument( 55 | "--offset", help="Offset to start at", default=0, type=int 56 | ) 57 | following_parser.add_argument( 58 | "--limit", help="Max items to get", default=10, type=int 59 | ) 60 | 61 | follow_parser = subparsers.add_parser("follow", help="Follow an actor") 62 | follow_parser.add_argument("id", help="id of actor to follow") 63 | 64 | pending_parser = subparsers.add_parser( 65 | "pending", help="Get pending follow requests" 66 | ) 67 | pending_subparsers = pending_parser.add_subparsers(dest="subsubcommand") 68 | 69 | pending_followers_parser = pending_subparsers.add_parser( 70 | "followers", help="Show pending incoming follow requests" 71 | ) 72 | pending_followers_parser.add_argument( 73 | "--offset", help="Offset to start at", default=0, type=int 74 | ) 75 | pending_followers_parser.add_argument( 76 | "--limit", help="Max items to get", default=10, type=int 77 | ) 78 | 79 | pending_following_parser = pending_subparsers.add_parser( 80 | "following", help="Show pending outgoing follow requests" 81 | ) 82 | pending_following_parser.add_argument( 83 | "--offset", help="Offset to start at", default=0, type=int 84 | ) 85 | pending_following_parser.add_argument( 86 | "--limit", help="Max items to get", default=10, type=int 87 | ) 88 | 89 | create_parser = subparsers.add_parser("create", help="Create objects") 90 | subsubparsers = create_parser.add_subparsers(dest="subsubcommand") 91 | 92 | note_parser = subsubparsers.add_parser("note", help="Create a note") 93 | note_parser.add_argument("source", nargs="+", help="Source of the note") 94 | group = note_parser.add_mutually_exclusive_group() 95 | group.add_argument( 96 | "--private", 97 | action="store_true", 98 | default=True, 99 | help="Whether the note is private", 100 | ) 101 | group.add_argument( 102 | "--public", action="store_true", help="Whether the note is public" 103 | ) 104 | group.add_argument( 105 | "--followers-only", 106 | action="store_true", 107 | help="Whether the note is followers-only", 108 | ) 109 | note_parser.add_argument("--to", type=str, action='append', help="Additional recipients") 110 | note_parser.add_argument( 111 | "--cc", type=str, action='append', help="Additional CC recipients" 112 | ) 113 | note_parser.add_argument('--in-reply-to', type=str, help="Object to reply to") 114 | 115 | coll_parser = subsubparsers.add_parser("collection", help="Create a collection") 116 | coll_parser.add_argument("name", nargs="+", help="Name of the collection") 117 | coll_group = coll_parser.add_mutually_exclusive_group() 118 | coll_group.add_argument( 119 | "--private", 120 | action="store_true", 121 | default=True, 122 | help="Whether the collection is private", 123 | ) 124 | coll_group.add_argument( 125 | "--public", action="store_true", help="Whether the collection is public" 126 | ) 127 | coll_group.add_argument( 128 | "--followers-only", 129 | action="store_true", 130 | help="Whether the collection is followers-only", 131 | ) 132 | coll_parser.add_argument("--to", type=str, action='append', help="Additional recipients") 133 | coll_parser.add_argument( 134 | "--cc", type=str, action='append', help="Additional CC recipients" 135 | ) 136 | 137 | accept_parser = subparsers.add_parser("accept", help="Accept an activity") 138 | accept_subparsers = accept_parser.add_subparsers(dest="subsubcommand") 139 | accept_follower_parser = accept_subparsers.add_parser( 140 | "follower", help="Accept a follower request" 141 | ) 142 | accept_follower_parser.add_argument("id", help="id of follower to accept") 143 | 144 | reject_parser = subparsers.add_parser("reject", help="Reject an activity") 145 | reject_subparsers = reject_parser.add_subparsers(dest="subsubcommand") 146 | reject_follower_parser = reject_subparsers.add_parser( 147 | "follower", help="Reject a follower request" 148 | ) 149 | reject_follower_parser.add_argument("id", help="id of follower to reject") 150 | 151 | undo_parser = subparsers.add_parser("undo", help="Undo an activity") 152 | undo_subparsers = undo_parser.add_subparsers(dest="subsubcommand") 153 | 154 | undo_follow_parser = undo_subparsers.add_parser("follow", help="Undo a follow") 155 | undo_follow_parser.add_argument("id", help="id of actor to stop following") 156 | 157 | undo_like_parser = undo_subparsers.add_parser("like", help="Undo a like") 158 | undo_like_parser.add_argument("id", help="id of object to unlike") 159 | 160 | undo_share_parser = undo_subparsers.add_parser("share", help="Undo a share") 161 | undo_share_parser.add_argument("id", help="id of object to unshare") 162 | 163 | upload_parser = subparsers.add_parser("upload", help="Upload a file") 164 | upload_parser.add_argument("filename", help="file name to upload") 165 | group = upload_parser.add_mutually_exclusive_group() 166 | group.add_argument( 167 | "--private", 168 | action="store_true", 169 | default=True, 170 | help="Whether the file is private", 171 | ) 172 | group.add_argument( 173 | "--public", action="store_true", help="Whether the file is public" 174 | ) 175 | group.add_argument( 176 | "--followers-only", 177 | action="store_true", 178 | help="Whether the file is followers-only", 179 | ) 180 | upload_parser.add_argument( 181 | "--to", type=str, nargs="+", help="Additional recipients" 182 | ) 183 | upload_parser.add_argument( 184 | "--cc", type=str, nargs="+", help="Additional CC recipients" 185 | ) 186 | upload_parser.add_argument("--title", help="Title of the file") 187 | upload_parser.add_argument("--description", help="Description of the file") 188 | 189 | delete_parser = subparsers.add_parser("delete", help="Delete an object") 190 | delete_parser.add_argument("id", help="id of object to delete") 191 | delete_parser.add_argument( 192 | "--force", action="store_true", help="Do not prompt for confirmation" 193 | ) 194 | 195 | update_parser = subparsers.add_parser("update", help="Update objects") 196 | update_subsubparsers = update_parser.add_subparsers(dest="subsubcommand") 197 | update_note_parser = update_subsubparsers.add_parser("note", help="Update a note") 198 | update_note_parser.add_argument("id", help="ID of the note to update") 199 | update_note_parser.add_argument("content", nargs="+", help="Content of the note") 200 | 201 | add_parser = subparsers.add_parser("add", help="Add objects to collections") 202 | add_parser.add_argument("--target", help="ID of the collection to add to") 203 | add_parser.add_argument("id", action='append', help="ID of the object(s) to add") 204 | 205 | remove_parser = subparsers.add_parser("remove", help="Remove objects from collections") 206 | remove_parser.add_argument("--target", help="ID of the collection to remove from") 207 | remove_parser.add_argument("id", action='append', help="ID(s) of the object(s) to remove") 208 | 209 | likes_parser = subparsers.add_parser("likes", help="Get likes of an object") 210 | likes_parser.add_argument("id", help="ID of the object to get likes of") 211 | likes_parser.add_argument( 212 | "--offset", help="Offset to start at", default=0, type=int 213 | ) 214 | likes_parser.add_argument("--limit", help="Max items to get", default=10, type=int) 215 | 216 | like_parser = subparsers.add_parser("like", help="Like an object") 217 | like_parser.add_argument("id", help="ID of the object to like") 218 | 219 | subparsers.add_parser("version", help="Show version information") 220 | 221 | shares_parser = subparsers.add_parser("shares", help="Get shares of an object") 222 | shares_parser.add_argument("id", help="ID of the object to get shares of") 223 | shares_parser.add_argument( 224 | "--offset", help="Offset to start at", default=0, type=int 225 | ) 226 | shares_parser.add_argument("--limit", help="Max items to get", default=10, type=int) 227 | 228 | share_parser = subparsers.add_parser("share", help="Share an object") 229 | share_parser.add_argument("id", help="ID of the object to share") 230 | 231 | replies_parser = subparsers.add_parser("replies", help="Get replies of an object") 232 | replies_parser.add_argument("id", help="ID of the object to get replies of") 233 | replies_parser.add_argument( 234 | "--offset", help="Offset to start at", default=0, type=int 235 | ) 236 | replies_parser.add_argument("--limit", help="Max items to get", default=10, type=int) 237 | 238 | return parser 239 | 240 | parser = make_parser() 241 | 242 | def get_command(args, env): 243 | """Get the command corresponding to the arguments""" 244 | 245 | command = None 246 | entry = None 247 | 248 | map = { 249 | "login": commands.LoginCommand, 250 | "logout": commands.LogoutCommand, 251 | "whoami": commands.WhoamiCommand, 252 | "get": commands.GetCommand, 253 | "inbox": commands.InboxCommand, 254 | "outbox": commands.OutboxCommand, 255 | "followers": commands.FollowersCommand, 256 | "following": commands.FollowingCommand, 257 | "follow": commands.FollowCommand, 258 | "create": { 259 | "note": commands.CreateNoteCommand, 260 | "collection": commands.CreateCollectionCommand, 261 | }, 262 | "pending": { 263 | "followers": commands.PendingFollowersCommand, 264 | "following": commands.PendingFollowingCommand, 265 | }, 266 | "accept": {"follower": commands.AcceptFollowerCommand}, 267 | "reject": {"follower": commands.RejectFollowerCommand}, 268 | "undo": { 269 | "follow": commands.UndoFollowCommand, 270 | "like": commands.UndoLikeCommand, 271 | "share": commands.UndoShareCommand, 272 | }, 273 | "upload": commands.UploadCommand, 274 | "delete": commands.DeleteCommand, 275 | "update": {"note": commands.UpdateNoteCommand}, 276 | "add": commands.AddCommand, 277 | "remove": commands.RemoveCommand, 278 | "likes": commands.LikesCommand, 279 | "version": commands.VersionCommand, 280 | "like": commands.LikeCommand, 281 | "shares": commands.SharesCommand, 282 | "share": commands.ShareCommand, 283 | "replies": commands.RepliesCommand 284 | } 285 | 286 | if args.subcommand in map: 287 | entry = map[args.subcommand] 288 | if isinstance(entry, dict): 289 | if args.subsubcommand in entry: 290 | entry = entry[args.subsubcommand] 291 | else: 292 | raise Exception("Invalid subsubcommand") 293 | else: 294 | raise Exception("Invalid subcommand") 295 | 296 | command = entry(args, env) 297 | 298 | return command 299 | 300 | def run_command(argv, env=None): 301 | 302 | """Run a command""" 303 | 304 | if env is None: 305 | env = os.environ 306 | 307 | args = parser.parse_args(argv) 308 | 309 | command = get_command(args, env) 310 | 311 | command.run() 312 | 313 | def main(): 314 | """Parse arguments and call corresponding commands""" 315 | 316 | run_command(sys.argv[1:], os.environ) 317 | 318 | 319 | if __name__ == "__main__": 320 | main() 321 | -------------------------------------------------------------------------------- /ap/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.3.0' 2 | -------------------------------------------------------------------------------- /docs/client.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | "https://purl.archive.org/socialweb/oauth" 5 | ], 6 | "id": "https://evanp.github.io/ap/client.jsonld", 7 | "type": "Application", 8 | "name": "ap", 9 | "summaryMap": { 10 | "en": "ap is a command-line client for the ActivityPub API. It can post and read notes, upload media, follow and unfollow users, and review the inbox and outbox, among other tasks." 11 | }, 12 | "icon": { 13 | "type": "Link", 14 | "href": "https://evanp.github.io/ap/icon-256.png", 15 | "mediaType": "image/png", 16 | "width": 256, 17 | "height": 256 18 | }, 19 | "url": "https://github.com/evanp/ap/", 20 | "attributedTo": { 21 | "type": "Person", 22 | "name": "Evan Prodromou", 23 | "id": "https://cosocial.ca/users/evan", 24 | "icon" : { 25 | "mediaType" : "image/png", 26 | "type" : "Image", 27 | "url" : "https://media.cosocial.ca/accounts/avatars/109/493/705/899/503/027/original/b3edd95717f7b7b3.png" 28 | } 29 | }, 30 | "redirectURI": "http://localhost:63546/callback" 31 | } 32 | -------------------------------------------------------------------------------- /docs/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evanp/ap/6771518ac1d8dd7f4706d40ffc1e51f5afd7178f/docs/icon-256.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from ap.version import __version__ 3 | 4 | # Function to read dependencies from Pipfile 5 | def read_requirements(): 6 | import toml # Ensure you install this dependency with `pip install toml` 7 | 8 | pipfile_data = toml.load('Pipfile') 9 | packages = pipfile_data.get('packages', {}) 10 | return [f"{pkg}{ver if ver != '*' else ''}" for pkg, ver in packages.items()] 11 | 12 | setup( 13 | name='ap', 14 | version=__version__, 15 | packages=find_packages(), 16 | entry_points={ 17 | 'console_scripts': [ 18 | 'ap=ap.main:main', # "ap" is the command, "ap.main:main" refers to the main function in your main.py 19 | ], 20 | }, 21 | install_requires=read_requirements(), 22 | author='Evan Prodromou', 23 | author_email='evan@prodromou.name', 24 | description='A simple command-line ActivityPub client', 25 | license='GPLv3', 26 | keywords=['activitypub', 'client', 'cli', 'social', 'fediverse', 'socialweb', 'api'], 27 | ) 28 | -------------------------------------------------------------------------------- /tests/test_accept_follower.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_1_ID = "https://different.example/users/other1" 13 | OTHER_2_ID = "https://social.example/users/other2" 14 | ACCEPT_ID = f"{ACTOR_ID}/accept/1" 15 | PENDING_FOLLOWERS_ID = f"{ACTOR_ID}/pending/followers" 16 | PAGE_2_ID = f"{PENDING_FOLLOWERS_ID}/page/2" 17 | PAGE_1_ID = f"{PENDING_FOLLOWERS_ID}/page/1" 18 | FOLLOW_1_ID = f"{OTHER_1_ID}/follows/1" 19 | FOLLOW_2_ID = f"{OTHER_2_ID}/follows/2" 20 | 21 | ACTOR = { 22 | "type": "Person", 23 | "id": ACTOR_ID, 24 | "outbox": f"{ACTOR_ID}/outbox", 25 | "pendingFollowers": PENDING_FOLLOWERS_ID, 26 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 27 | } 28 | 29 | OTHER_1 = { 30 | "type": "Person", 31 | "id": OTHER_1_ID, 32 | "preferredUsername": "other1", 33 | "inbox": f"{OTHER_1_ID}/inbox", 34 | } 35 | 36 | OTHER_2 = { 37 | "type": "Person", 38 | "id": OTHER_1_ID, 39 | "preferredUsername": "other2", 40 | "inbox": f"{OTHER_2_ID}/inbox", 41 | } 42 | 43 | 44 | PENDING_FOLLOWERS = { 45 | "id": PENDING_FOLLOWERS_ID, 46 | "attributedTo": ACTOR_ID, 47 | "first": PAGE_2_ID, 48 | } 49 | 50 | FOLLOW_1 = { 51 | "id": FOLLOW_1_ID, 52 | "actor": OTHER_1_ID, 53 | "type": "Follow", 54 | "object": ACTOR_ID, 55 | "published": "2020-01-01T00:00:00Z", 56 | } 57 | 58 | FOLLOW_2 = { 59 | "id": FOLLOW_2_ID, 60 | "actor": OTHER_2_ID, 61 | "type": "Follow", 62 | "object": ACTOR_ID, 63 | "published": "2020-01-01T00:00:00Z", 64 | } 65 | 66 | PAGE_2 = { 67 | "id": PAGE_2_ID, 68 | "partOf": PENDING_FOLLOWERS_ID, 69 | "next": PAGE_1_ID, 70 | "orderedItems": [FOLLOW_1], 71 | } 72 | 73 | PAGE_1 = { 74 | "id": PAGE_1_ID, 75 | "partOf": PENDING_FOLLOWERS_ID, 76 | "prev": PAGE_2_ID, 77 | "orderedItems": [FOLLOW_2], 78 | } 79 | 80 | TOKEN_FILE_DATA = json.dumps( 81 | {"actor_id": "https://social.example/users/evanp", "access_token": "12345678"} 82 | ) 83 | 84 | 85 | def mock_oauth_get(url, headers=None): 86 | if url == ACTOR_ID: 87 | return MagicMock(status_code=200, json=lambda: ACTOR) 88 | elif url == PENDING_FOLLOWERS_ID: 89 | return MagicMock(status_code=200, json=lambda: PENDING_FOLLOWERS) 90 | elif url == PAGE_2_ID: 91 | return MagicMock(status_code=200, json=lambda: PAGE_2) 92 | elif url == PAGE_1_ID: 93 | return MagicMock(status_code=200, json=lambda: PAGE_1) 94 | elif url == OTHER_2_ID: 95 | return MagicMock(status_code=200, json=lambda: OTHER_2) 96 | elif url == FOLLOW_2_ID: 97 | return MagicMock(status_code=200, json=lambda: FOLLOW_2) 98 | else: 99 | return MagicMock(status_code=404) 100 | 101 | 102 | def mock_oauth_post(url, headers=None, data=None): 103 | if url == ACTOR["endpoints"]["proxyUrl"]: 104 | if data["id"] == OTHER_1_ID: 105 | return MagicMock(status_code=200, json=lambda: OTHER_1) 106 | if data["id"] == FOLLOW_1_ID: 107 | return MagicMock(status_code=200, json=lambda: FOLLOW_1) 108 | else: 109 | return MagicMock(status_code=404) 110 | elif url == ACTOR["outbox"]: 111 | added_data = { 112 | "id": ACCEPT_ID, 113 | "published": "2020-01-01T00:00:00Z", 114 | "actor": ACTOR_ID, 115 | } 116 | return MagicMock( 117 | status_code=200, json=lambda: {**json.loads(data), **added_data} 118 | ) 119 | else: 120 | return MagicMock(status_code=404) 121 | 122 | 123 | class TestAcceptFollowerCommand(unittest.TestCase): 124 | def setUp(self): 125 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 126 | 127 | def tearDown(self): 128 | sys.stdout = self.held 129 | 130 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 131 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 132 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 133 | def test_accept_follower_remote( 134 | self, mock_requests_get, mock_requests_post, mock_file 135 | ): 136 | run_command(["accept", "follower", OTHER_1_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 137 | 138 | # Assertions 139 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 140 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 141 | self.assertIn(OTHER_1_ID, sys.stdout.getvalue()) 142 | 143 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 144 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 145 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 146 | def test_accept_follower_local( 147 | self, mock_requests_get, mock_requests_post, mock_file 148 | ): 149 | run_command(["accept", "follower", OTHER_2_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 150 | 151 | # Assertions 152 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 153 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 154 | self.assertIn(OTHER_2_ID, sys.stdout.getvalue()) 155 | 156 | 157 | if __name__ == "__main__": 158 | unittest.main() 159 | -------------------------------------------------------------------------------- /tests/test_add.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | COLLECTION_ID = f"{ACTOR_ID}/collection/1" 13 | OBJECT_ID = f"{ACTOR_ID}/object/1" 14 | ADD_ID = f"{ACTOR_ID}/add/1" 15 | 16 | ACTOR = { 17 | "type": "Person", 18 | "id": ACTOR_ID, 19 | "outbox": f"{ACTOR_ID}/outbox", 20 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 21 | } 22 | 23 | COLLECTION = { 24 | "id": COLLECTION_ID, 25 | "attributedTo": ACTOR_ID, 26 | "type": "Collection", 27 | "name": "A generic collection", 28 | "totalItems": 0, 29 | "items": [], 30 | } 31 | 32 | OBJECT = { 33 | "id": OBJECT_ID, 34 | "attributedTo": ACTOR_ID, 35 | "type": "Object", 36 | "name": "A generic object", 37 | } 38 | 39 | TOKEN_FILE_DATA = json.dumps( 40 | {"actor_id": "https://social.example/users/evanp", "access_token": "12345678"} 41 | ) 42 | 43 | def mock_oauth_get(url, headers=None): 44 | if url == ACTOR_ID: 45 | return MagicMock(status_code=200, json=lambda: ACTOR) 46 | elif url == COLLECTION_ID: 47 | return MagicMock(status_code=200, json=lambda: COLLECTION) 48 | elif url == OBJECT_ID: 49 | return MagicMock(status_code=200, json=lambda: OBJECT) 50 | else: 51 | return MagicMock(status_code=404) 52 | 53 | def mock_oauth_post(url, headers=None, data=None): 54 | if url == ACTOR["outbox"]: 55 | input_data = json.loads(data) 56 | added_data = { 57 | "id": ADD_ID, 58 | "published": "2020-01-01T00:00:00Z", 59 | "actor": ACTOR_ID 60 | } 61 | return MagicMock( 62 | status_code=200, json=lambda: {**input_data, **added_data} 63 | ) 64 | else: 65 | return MagicMock(status_code=404) 66 | 67 | class TestAdd(unittest.TestCase): 68 | 69 | def setUp(self): 70 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 71 | 72 | def tearDown(self): 73 | sys.stdout = self.held 74 | 75 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 76 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 77 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 78 | def test_add( 79 | self, mock_requests_get, mock_requests_post, mock_file 80 | ): 81 | run_command(["add", "--target", COLLECTION_ID, OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 82 | 83 | # Assertions 84 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 85 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 86 | self.assertIn(ADD_ID, sys.stdout.getvalue()) -------------------------------------------------------------------------------- /tests/test_create_collection.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | CREATE_ID = f"{ACTOR_ID}/create/1" 14 | NAME = "Photos from Bali" 15 | COLLECTION_ID = f"{ACTOR_ID}/collections/1" 16 | 17 | ACTOR = { 18 | "type": "Person", 19 | "id": ACTOR_ID, 20 | "outbox": f"{ACTOR_ID}/outbox", 21 | "followers": f"{ACTOR_ID}/followers", 22 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 23 | } 24 | 25 | OTHER = { 26 | "type": "Person", 27 | "id": OTHER_ID, 28 | "outbox": f"{OTHER_ID}/outbox", 29 | "followers": f"{OTHER_ID}/followers", 30 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 31 | } 32 | 33 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 34 | 35 | 36 | def mock_oauth_get(url, headers=None): 37 | if url == ACTOR_ID: 38 | return MagicMock(status_code=200, json=lambda: ACTOR) 39 | elif url == OTHER_ID: 40 | return MagicMock(status_code=200, json=lambda: OTHER) 41 | else: 42 | return MagicMock(status_code=404) 43 | 44 | posted = [] 45 | 46 | def mock_oauth_post(url, headers=None, data=None): 47 | global posted 48 | if url == ACTOR["outbox"]: 49 | input_data = json.loads(data) 50 | posted.append(input_data) 51 | input_object = input_data["object"] 52 | added_object = {'id': COLLECTION_ID} 53 | added_data = { 54 | "id": CREATE_ID, 55 | "published": "2020-01-01T00:00:00Z", 56 | "actor": ACTOR_ID, 57 | "object": {**input_object, **added_object} 58 | } 59 | return MagicMock( 60 | status_code=200, json=lambda: {**input_data, **added_data} 61 | ) 62 | else: 63 | return MagicMock(status_code=404) 64 | 65 | 66 | class TestCreateCollectionCommand(unittest.TestCase): 67 | def setUp(self): 68 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 69 | 70 | def tearDown(self): 71 | sys.stdout = self.held 72 | 73 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 74 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 75 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 76 | def test_create_collection_public(self, mock_requests_get, mock_requests_post, mock_file): 77 | global posted 78 | 79 | posted = [] 80 | run_command(["create", "collection", "--public", NAME], {"LANG": "en_US.UTF-8", 'HOME': '/home/notauser'}) 81 | 82 | # Assertions 83 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 84 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 85 | self.assertIn(NAME, sys.stdout.getvalue()) 86 | self.assertIn("Public", sys.stdout.getvalue()) 87 | self.assertEqual(len(posted), 1) 88 | self.assertEqual(posted[0]["to"], ["https://www.w3.org/ns/activitystreams#Public"]) 89 | self.assertEqual(posted[0]["object"]["type"], "Collection") 90 | 91 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 92 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 93 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 94 | def test_create_collection_followers_only( 95 | self, mock_requests_get, mock_requests_post, mock_file 96 | ): 97 | global posted 98 | posted = [] 99 | run_command(["create", "collection", "--followers-only", NAME], {"LANG": "en_US.UTF-8", 'HOME': '/home/notauser'}) 100 | 101 | # Assertions 102 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 103 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 104 | self.assertIn(NAME, sys.stdout.getvalue()) 105 | self.assertIn(ACTOR["followers"], sys.stdout.getvalue()) 106 | self.assertEqual(len(posted), 1) 107 | self.assertEqual(posted[0]["to"], [ACTOR["followers"]]) 108 | self.assertEqual(posted[0]["object"]["type"], "Collection") 109 | 110 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 111 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 112 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 113 | def test_create_collection_private( 114 | self, mock_requests_get, mock_requests_post, mock_file 115 | ): 116 | global posted 117 | posted = [] 118 | 119 | run_command(["create", "collection", '--to', OTHER_ID, NAME], {"LANG": "en_US.UTF-8", 'HOME': '/home/notauser'}) 120 | 121 | # Assertions 122 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 123 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 124 | self.assertIn(NAME, sys.stdout.getvalue()) 125 | self.assertIn(OTHER_ID, sys.stdout.getvalue()) 126 | self.assertEqual(len(posted), 1) 127 | self.assertEqual(posted[0]["to"], [OTHER_ID]) 128 | self.assertEqual(posted[0]["object"]["type"], "Collection") 129 | 130 | 131 | if __name__ == "__main__": 132 | unittest.main() 133 | -------------------------------------------------------------------------------- /tests/test_create_note.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | import io 5 | import sys 6 | import requests 7 | from requests_oauthlib import OAuth2Session 8 | import json 9 | import logging 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | CREATE_ID = f"{ACTOR_ID}/create/1" 14 | ORIGINAL_POST_ID = f"{OTHER_ID}/note/2" 15 | 16 | ACTOR = { 17 | "type": "Person", 18 | "id": ACTOR_ID, 19 | "outbox": f"{ACTOR_ID}/outbox", 20 | "followers": f"{ACTOR_ID}/followers", 21 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 22 | } 23 | 24 | OTHER = { 25 | "type": "Person", 26 | "id": OTHER_ID, 27 | "outbox": f"{OTHER_ID}/outbox", 28 | "followers": f"{OTHER_ID}/followers", 29 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 30 | "url": "https://social.example/user/other", 31 | "preferredUsername": "other" 32 | } 33 | 34 | OTHER_WEBFINGER_ID = "other@social.example" 35 | OTHER_WEBFINGER_JSON = { 36 | "subject": "acct:other@social.example", 37 | "links": [{"rel": "self", 38 | "type": "application/activity+json", 39 | "href": OTHER_ID}] 40 | } 41 | 42 | WEBFINGER_URL_BASE = "https://social.example/.well-known/webfinger" 43 | OTHER_WEBFINGER_URL = WEBFINGER_URL_BASE + "?resource=acct%3Aother%40social.example" 44 | 45 | ORIGINAL_POST = { 46 | "type": "Note", 47 | "id": ORIGINAL_POST_ID, 48 | "contentMap": { 49 | "en": "What should we say to the world?" 50 | }, 51 | "attributedTo": OTHER_ID, 52 | "published": "2020-01-01T00:00:00Z", 53 | } 54 | 55 | CONTENT = "Hello, world!" 56 | AT_MENTION_CONTENT = f"Hello, @other@social.example" 57 | AT_MENTION_LINK = f" @other@social.example" 58 | HASHTAG_CONTENT = f"Hello, World! #greeting" 59 | HASHTAG_LINK = f'#greeting' 60 | LINK_CONTENT = f"Hello, https://example.com" 61 | LINK_LINK = f"https://example.com" 62 | MARKUP_CONTENT = f"This > that & that < this" 63 | MARKUP_HTML = f"This > that & that < this" 64 | 65 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 66 | 67 | def mock_oauth_get(url, headers=None): 68 | if url == ACTOR_ID: 69 | return MagicMock(status_code=200, json=lambda: ACTOR) 70 | elif url == OTHER_ID: 71 | return MagicMock(status_code=200, json=lambda: OTHER) 72 | elif url == ORIGINAL_POST_ID: 73 | return MagicMock(status_code=200, json=lambda: ORIGINAL_POST) 74 | else: 75 | return MagicMock(status_code=404) 76 | 77 | 78 | def mock_oauth_post(url, headers=None, data=None): 79 | if url == ACTOR["outbox"]: 80 | added_data = { 81 | "id": CREATE_ID, 82 | "published": "2020-01-01T00:00:00Z", 83 | "actor": ACTOR_ID, 84 | } 85 | combined = {**json.loads(data), **added_data} 86 | return MagicMock( 87 | status_code=200, json=lambda: combined 88 | ) 89 | else: 90 | return MagicMock(status_code=404) 91 | 92 | 93 | def mock_requests_get(url, **kwargs): 94 | if url == WEBFINGER_URL_BASE: 95 | return MagicMock( 96 | status_code=200, 97 | headers={"Content-Type": "application/jrd+json"}, 98 | json=lambda: OTHER_WEBFINGER_JSON, 99 | ) 100 | else: 101 | return MagicMock(status_code=404) 102 | 103 | 104 | class TestCreateNoteCommand(unittest.TestCase): 105 | def setUp(self): 106 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 107 | 108 | def tearDown(self): 109 | sys.stdout = self.held 110 | 111 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 112 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 113 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 114 | def test_create_note_public(self, mock_oauth_get, mock_oauth_post, mock_file): 115 | run_command(["create", "note", "--public", CONTENT], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 116 | 117 | # Assertions 118 | self.assertGreaterEqual(mock_oauth_get.call_count, 1) 119 | self.assertGreaterEqual(mock_oauth_post.call_count, 1) 120 | self.assertIn(CONTENT, sys.stdout.getvalue()) 121 | self.assertIn("Public", sys.stdout.getvalue()) 122 | 123 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 124 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 125 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 126 | def test_create_note_followers_only( 127 | self, mock_oauth_get, mock_oauth_post, mock_file 128 | ): 129 | run_command(["create", "note", "--followers-only", CONTENT], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 130 | 131 | # Assertions 132 | self.assertGreaterEqual(mock_oauth_get.call_count, 1) 133 | self.assertGreaterEqual(mock_oauth_post.call_count, 1) 134 | self.assertIn(CONTENT, sys.stdout.getvalue()) 135 | self.assertIn(ACTOR["followers"], sys.stdout.getvalue()) 136 | 137 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 138 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 139 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 140 | def test_create_note_private(self, mock_oauth_get, mock_oauth_post, mock_file): 141 | run_command(["create", "note", '--to', OTHER_ID, CONTENT], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 142 | 143 | # Assertions 144 | self.assertGreaterEqual(mock_oauth_get.call_count, 1) 145 | self.assertGreaterEqual(mock_oauth_post.call_count, 1) 146 | self.assertIn(CONTENT, sys.stdout.getvalue()) 147 | self.assertIn(OTHER_ID, sys.stdout.getvalue()) 148 | 149 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 150 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 151 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 152 | def test_create_note_reply(self, mock_oauth_get, mock_oauth_post, mock_file): 153 | run_command(["create", "note", '--public', '--in-reply-to', ORIGINAL_POST_ID, CONTENT], 154 | {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 155 | 156 | # Assertions 157 | self.assertGreaterEqual(mock_oauth_get.call_count, 1) 158 | self.assertGreaterEqual(mock_oauth_post.call_count, 1) 159 | self.assertIn(CONTENT, sys.stdout.getvalue()) 160 | self.assertIn(ORIGINAL_POST_ID, sys.stdout.getvalue()) 161 | 162 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 163 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 164 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 165 | @patch("requests.get", side_effect=mock_requests_get) 166 | def test_create_note_at_mention( 167 | self, mock_requests_get, mock_oauth_get, mock_oauth_post, mock_file 168 | ): 169 | run_command(["create", "note", '--public', AT_MENTION_CONTENT], 170 | {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 171 | 172 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 173 | self.assertGreaterEqual(mock_oauth_get.call_count, 1) 174 | self.assertGreaterEqual(mock_oauth_post.call_count, 1) 175 | activity = json.loads(sys.stdout.getvalue()) 176 | self.assertIsNotNone(activity["object"]) 177 | object = activity["object"] 178 | self.assertIn(AT_MENTION_CONTENT, object["source"]["content"]) 179 | self.assertIn(AT_MENTION_LINK, object["content"]) 180 | self.assertEqual(len(object["tag"]), 1) 181 | self.assertEqual(object['tag'][0]['type'], 'Mention') 182 | self.assertEqual(object['tag'][0]['href'], OTHER['url']) 183 | self.assertEqual(object['tag'][0]['name'], '@other@social.example') 184 | 185 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 186 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 187 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 188 | def test_create_note_hashtag(self, mock_oauth_get, mock_oauth_post, mock_file): 189 | run_command(["create", "note", '--public', HASHTAG_CONTENT], 190 | {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 191 | 192 | # Assertions 193 | self.assertGreaterEqual(mock_oauth_get.call_count, 1) 194 | self.assertGreaterEqual(mock_oauth_post.call_count, 1) 195 | activity = json.loads(sys.stdout.getvalue()) 196 | self.assertIsNotNone(activity["object"]) 197 | object = activity["object"] 198 | self.assertIn(HASHTAG_CONTENT, object["source"]["content"]) 199 | self.assertIn(HASHTAG_LINK, object["content"]) 200 | self.assertEqual(len(object["tag"]), 1) 201 | self.assertEqual(object['tag'][0]['type'], 'Hashtag') 202 | self.assertEqual(object["tag"][0]["href"], "https://tags.pub/greeting") 203 | self.assertEqual(object["tag"][0]["name"], "#greeting") 204 | 205 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 206 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 207 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 208 | def test_create_note_link(self, mock_oauth_get, mock_oauth_post, mock_file): 209 | run_command(["create", "note", '--public', LINK_CONTENT], 210 | {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 211 | 212 | # Assertions 213 | self.assertGreaterEqual(mock_oauth_get.call_count, 1) 214 | self.assertGreaterEqual(mock_oauth_post.call_count, 1) 215 | activity = json.loads(sys.stdout.getvalue()) 216 | self.assertIsNotNone(activity["object"]) 217 | object = activity["object"] 218 | self.assertIn(LINK_CONTENT, object["source"]["content"]) 219 | self.assertIn(LINK_LINK, object["content"]) 220 | 221 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 222 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 223 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 224 | def test_create_note_escape(self, mock_oauth_get, mock_oauth_post, mock_file): 225 | run_command(["create", "note", '--public', MARKUP_CONTENT], 226 | {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 227 | 228 | # Assertions 229 | self.assertGreaterEqual(mock_oauth_get.call_count, 1) 230 | self.assertGreaterEqual(mock_oauth_post.call_count, 1) 231 | activity = json.loads(sys.stdout.getvalue()) 232 | self.assertIsNotNone(activity["object"]) 233 | object = activity["object"] 234 | self.assertIn(MARKUP_CONTENT, object["source"]["content"]) 235 | self.assertIn(MARKUP_HTML, object["content"]) 236 | 237 | 238 | if __name__ == "__main__": 239 | unittest.main() 240 | -------------------------------------------------------------------------------- /tests/test_delete.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | NOTE_ID = "https://social.example/users/evanp/note/1" 13 | ACTOR = { 14 | "type": "Person", 15 | "id": ACTOR_ID, 16 | "outbox": "https://social.example/users/evanp/outbox", 17 | } 18 | NOTE = {"type": "Note", "id": NOTE_ID, "content": "Hello World"} 19 | TOKEN_FILE_DATA = json.dumps( 20 | {"actor_id": "https://social.example/users/evanp", "access_token": "12345678"} 21 | ) 22 | 23 | 24 | def mock_oauth_get(url, headers=None): 25 | if url == ACTOR_ID: 26 | return MagicMock(status_code=200, json=lambda: ACTOR) 27 | elif url == NOTE_ID: 28 | return MagicMock(status_code=200, json=lambda: NOTE) 29 | else: 30 | return MagicMock(status_code=404) 31 | 32 | 33 | class TestDeleteCommand(unittest.TestCase): 34 | def setUp(self): 35 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 36 | 37 | def tearDown(self): 38 | sys.stdout = self.held 39 | 40 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 41 | @patch("requests_oauthlib.OAuth2Session.post") 42 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 43 | @patch("builtins.input", return_value="y") 44 | def test_delete_with_confirmation( 45 | self, mock_input, mock_requests_get, mock_oauth_post, mock_file 46 | ): 47 | 48 | mock_oauth_post.return_value = MagicMock( 49 | status_code=200, json=lambda: {"success": True} 50 | ) 51 | 52 | run_command(["delete", NOTE_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 53 | 54 | # Assertions 55 | self.assertEqual(mock_requests_get.call_count, 2) 56 | mock_oauth_post.assert_called_once() 57 | mock_input.assert_called_once() 58 | self.assertIn("Deleted.", sys.stdout.getvalue()) 59 | 60 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 61 | @patch("requests_oauthlib.OAuth2Session.post") 62 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 63 | @patch("builtins.input", return_value="y") 64 | def test_delete_with_force( 65 | self, mock_input, mock_requests_get, mock_oauth_post, mock_file 66 | ): 67 | 68 | mock_oauth_post.return_value = MagicMock( 69 | status_code=200, json=lambda: {"success": True} 70 | ) 71 | 72 | run_command(["delete", "--force", NOTE_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 73 | 74 | # Assertions 75 | self.assertEqual(mock_requests_get.call_count, 2) 76 | mock_oauth_post.assert_called_once() 77 | self.assertEqual(mock_input.call_count, 0) 78 | self.assertIn("Deleted.", sys.stdout.getvalue()) 79 | 80 | 81 | if __name__ == "__main__": 82 | unittest.main() 83 | -------------------------------------------------------------------------------- /tests/test_follow.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_1_ID = "https://different.example/users/other1" 13 | OTHER_2_ID = "https://social.example/users/other2" 14 | FOLLOW_ID = f"{ACTOR_ID}/follows/1" 15 | 16 | ACTOR = { 17 | "type": "Person", 18 | "id": ACTOR_ID, 19 | "outbox": f"{ACTOR_ID}/outbox", 20 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 21 | } 22 | 23 | OTHER_1 = { 24 | "type": "Person", 25 | "id": OTHER_1_ID, 26 | "preferredUsername": "other1", 27 | "inbox": f"{OTHER_1_ID}/inbox", 28 | } 29 | 30 | OTHER_2 = { 31 | "type": "Person", 32 | "id": OTHER_1_ID, 33 | "preferredUsername": "other2", 34 | "inbox": f"{OTHER_2_ID}/inbox", 35 | } 36 | 37 | TOKEN_FILE_DATA = json.dumps( 38 | {"actor_id": "https://social.example/users/evanp", "access_token": "12345678"} 39 | ) 40 | 41 | 42 | def mock_oauth_get(url, headers=None): 43 | if url == ACTOR_ID: 44 | return MagicMock(status_code=200, json=lambda: ACTOR) 45 | elif url == OTHER_2_ID: 46 | return MagicMock(status_code=200, json=lambda: OTHER_2) 47 | else: 48 | return MagicMock(status_code=404) 49 | 50 | 51 | def mock_oauth_post(url, headers=None, data=None): 52 | if url == ACTOR["endpoints"]["proxyUrl"]: 53 | if data["id"] == OTHER_1_ID: 54 | return MagicMock(status_code=200, json=lambda: OTHER_1) 55 | else: 56 | return MagicMock(status_code=404) 57 | elif url == ACTOR["outbox"]: 58 | added_data = { 59 | "id": FOLLOW_ID, 60 | "published": "2020-01-01T00:00:00Z", 61 | "actor": ACTOR_ID, 62 | } 63 | return MagicMock( 64 | status_code=200, json=lambda: {**json.loads(data), **added_data} 65 | ) 66 | else: 67 | return MagicMock(status_code=404) 68 | 69 | 70 | class TestFollowCommand(unittest.TestCase): 71 | def setUp(self): 72 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 73 | 74 | def tearDown(self): 75 | sys.stdout = self.held 76 | 77 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 78 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 79 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 80 | def test_follow_remote(self, mock_requests_get, mock_requests_post, mock_file): 81 | 82 | run_command(["follow", OTHER_1_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 83 | 84 | # Assertions 85 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 86 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 87 | self.assertIn(FOLLOW_ID, sys.stdout.getvalue()) 88 | 89 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 90 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 91 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 92 | def test_follow_local(self, mock_requests_get, mock_requests_post, mock_file): 93 | 94 | run_command(["follow", OTHER_2_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 95 | 96 | # Assertions 97 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 98 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 99 | self.assertIn(FOLLOW_ID, sys.stdout.getvalue()) 100 | 101 | 102 | if __name__ == "__main__": 103 | unittest.main() 104 | -------------------------------------------------------------------------------- /tests/test_followers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_1_ID = "https://different.example/users/other1" 13 | OTHER_2_ID = "https://social.example/users/other2" 14 | FOLLOWERS_ID = f"{ACTOR_ID}/followers" 15 | PAGE_2_ID = f"{FOLLOWERS_ID}/page/2" 16 | PAGE_1_ID = f"{FOLLOWERS_ID}/page/1" 17 | 18 | ACTOR = { 19 | "type": "Person", 20 | "id": ACTOR_ID, 21 | "outbox": f"{ACTOR_ID}/outbox", 22 | "inbox": f"{ACTOR_ID}/outbox", 23 | "followers": FOLLOWERS_ID, 24 | "preferredUsername": "evanp", 25 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 26 | } 27 | 28 | OTHER_1 = { 29 | "type": "Person", 30 | "id": OTHER_1_ID, 31 | "outbox": f"{OTHER_1_ID}/outbox", 32 | "inbox": f"{OTHER_1_ID}/inbox", 33 | "preferredUsername": "other1", 34 | } 35 | 36 | OTHER_2 = { 37 | "type": "Person", 38 | "id": OTHER_2_ID, 39 | "outbox": f"{OTHER_2_ID}/outbox", 40 | "inbox": f"{OTHER_2_ID}/inbox", 41 | "preferredUsername": "other2", 42 | } 43 | 44 | FOLLOWERS = {"id": FOLLOWERS_ID, "attributedTo": ACTOR_ID, "first": PAGE_2_ID} 45 | 46 | PAGE_2 = { 47 | "id": PAGE_2_ID, 48 | "partOf": FOLLOWERS_ID, 49 | "next": PAGE_1_ID, 50 | "orderedItems": [ 51 | {"id": OTHER_1_ID, "type": "Person", "preferredUsername": "other1"} 52 | ], 53 | } 54 | 55 | PAGE_1 = { 56 | "id": PAGE_1_ID, 57 | "partOf": FOLLOWERS_ID, 58 | "prev": PAGE_2_ID, 59 | "orderedItems": [ 60 | {"id": OTHER_2_ID, "type": "Person", "preferredUsername": "other2"} 61 | ], 62 | } 63 | 64 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 65 | 66 | 67 | def mock_oauth_get(url, headers=None): 68 | if url == ACTOR_ID: 69 | return MagicMock(status_code=200, json=lambda: ACTOR) 70 | elif url == FOLLOWERS_ID: 71 | return MagicMock(status_code=200, json=lambda: FOLLOWERS) 72 | elif url == PAGE_2_ID: 73 | return MagicMock(status_code=200, json=lambda: PAGE_2) 74 | elif url == PAGE_1_ID: 75 | return MagicMock(status_code=200, json=lambda: PAGE_1) 76 | elif url == OTHER_2_ID: 77 | return MagicMock(status_code=200, json=lambda: OTHER_2) 78 | else: 79 | return MagicMock(status_code=404) 80 | 81 | 82 | def mock_oauth_post(url, headers=None, data=None): 83 | if url == ACTOR["endpoints"]["proxyUrl"]: 84 | if data["id"] == OTHER_1_ID: 85 | return MagicMock(status_code=200, json=lambda: OTHER_1) 86 | else: 87 | return MagicMock(status_code=404) 88 | else: 89 | return MagicMock(status_code=404) 90 | 91 | 92 | class TestFollowersCommand(unittest.TestCase): 93 | def setUp(self): 94 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 95 | 96 | def tearDown(self): 97 | sys.stdout = self.held 98 | 99 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 100 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 101 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 102 | def test_followers(self, mock_requests_post, mock_requests_get, mock_file): 103 | run_command(["followers"], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 104 | 105 | # Assertions 106 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 107 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 108 | self.assertIn("other2@social.example", sys.stdout.getvalue()) 109 | self.assertIn("other1@different.example", sys.stdout.getvalue()) 110 | 111 | 112 | if __name__ == "__main__": 113 | unittest.main() 114 | -------------------------------------------------------------------------------- /tests/test_following.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_1_ID = "https://different.example/users/other1" 13 | OTHER_2_ID = "https://social.example/users/other2" 14 | FOLLOWING_ID = f"{ACTOR_ID}/followers" 15 | PAGE_2_ID = f"{FOLLOWING_ID}/page/2" 16 | PAGE_1_ID = f"{FOLLOWING_ID}/page/1" 17 | 18 | ACTOR = { 19 | "type": "Person", 20 | "id": ACTOR_ID, 21 | "outbox": f"{ACTOR_ID}/outbox", 22 | "inbox": f"{ACTOR_ID}/outbox", 23 | "following": FOLLOWING_ID, 24 | "preferredUsername": "evanp", 25 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 26 | } 27 | 28 | OTHER_1 = { 29 | "type": "Person", 30 | "id": OTHER_1_ID, 31 | "outbox": f"{OTHER_1_ID}/outbox", 32 | "inbox": f"{OTHER_1_ID}/inbox", 33 | "preferredUsername": "other1", 34 | } 35 | 36 | OTHER_2 = { 37 | "type": "Person", 38 | "id": OTHER_2_ID, 39 | "outbox": f"{OTHER_2_ID}/outbox", 40 | "inbox": f"{OTHER_2_ID}/inbox", 41 | "preferredUsername": "other2", 42 | } 43 | 44 | FOLLOWING = {"id": FOLLOWING_ID, "attributedTo": ACTOR_ID, "first": PAGE_2_ID} 45 | 46 | PAGE_2 = { 47 | "id": PAGE_2_ID, 48 | "partOf": FOLLOWING_ID, 49 | "next": PAGE_1_ID, 50 | "orderedItems": [ 51 | {"id": OTHER_1_ID, "type": "Person", "preferredUsername": "other1"} 52 | ], 53 | } 54 | 55 | PAGE_1 = { 56 | "id": PAGE_1_ID, 57 | "partOf": FOLLOWING_ID, 58 | "prev": PAGE_2_ID, 59 | "orderedItems": [ 60 | {"id": OTHER_2_ID, "type": "Person", "preferredUsername": "other2"} 61 | ], 62 | } 63 | 64 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 65 | 66 | 67 | def mock_oauth_get(url, headers=None): 68 | if url == ACTOR_ID: 69 | return MagicMock(status_code=200, json=lambda: ACTOR) 70 | elif url == FOLLOWING_ID: 71 | return MagicMock(status_code=200, json=lambda: FOLLOWING) 72 | elif url == PAGE_2_ID: 73 | return MagicMock(status_code=200, json=lambda: PAGE_2) 74 | elif url == PAGE_1_ID: 75 | return MagicMock(status_code=200, json=lambda: PAGE_1) 76 | elif url == OTHER_2_ID: 77 | return MagicMock(status_code=200, json=lambda: OTHER_2) 78 | else: 79 | return MagicMock(status_code=404) 80 | 81 | 82 | def mock_oauth_post(url, headers=None, data=None): 83 | if url == ACTOR["endpoints"]["proxyUrl"]: 84 | if data["id"] == OTHER_1_ID: 85 | return MagicMock(status_code=200, json=lambda: OTHER_1) 86 | else: 87 | return MagicMock(status_code=404) 88 | else: 89 | return MagicMock(status_code=404) 90 | 91 | 92 | class TestFollowingCommand(unittest.TestCase): 93 | def setUp(self): 94 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 95 | 96 | def tearDown(self): 97 | sys.stdout = self.held 98 | 99 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 100 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 101 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 102 | def test_following(self, mock_requests_post, mock_requests_get, mock_file): 103 | 104 | run_command(["following"], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 105 | 106 | # Assertions 107 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 108 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 109 | self.assertIn("other2@social.example", sys.stdout.getvalue()) 110 | self.assertIn("other1@different.example", sys.stdout.getvalue()) 111 | 112 | 113 | if __name__ == "__main__": 114 | unittest.main() 115 | -------------------------------------------------------------------------------- /tests/test_get.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | from ap.version import __version__ 11 | import logging 12 | 13 | USER_AGENT = f"ap/{__version__}" 14 | ACTOR_ID = "https://social.example/users/evanp" 15 | NOTE_ID = "https://social.example/users/evanp/note/1" 16 | ACTOR = { 17 | "type": "Person", 18 | "id": ACTOR_ID, 19 | "outbox": "https://social.example/users/evanp/outbox", 20 | } 21 | NOTE = {"type": "Note", "id": NOTE_ID, "content": "Hello World"} 22 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 23 | ACTOR_WEBFINGER_ID = "evanp@social.example" 24 | ACTOR_WEBFINGER_JSON = { 25 | "subject": "acct:evanp@social.example", 26 | "links": [{"rel": "self", 27 | "type": "application/activity+json", 28 | "href": ACTOR_ID}] 29 | } 30 | 31 | WEBFINGER_URL_BASE = "https://social.example/.well-known/webfinger" 32 | ACTOR_WEBFINGER_URL = WEBFINGER_URL_BASE + "?resource=acct%3Aevanp%40social.example" 33 | 34 | ACTOR2_ID = "https://social.example/@other" 35 | NOTE2_ID = "https://social.example/@other/123456789012345678" 36 | ACTOR2 = { 37 | "type": "Person", 38 | "id": ACTOR2_ID, 39 | "outbox": "https://social.example/@other/outbox", 40 | } 41 | NOTE2 = {"type": "Note", "id": NOTE2_ID, "content": "Hello World"} 42 | 43 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 44 | 45 | 46 | request_headers = [] 47 | 48 | def mock_oauth_get(url, headers=None): 49 | global request_headers 50 | request_headers.append(headers) 51 | if url == ACTOR_ID: 52 | return MagicMock(status_code=200, json=lambda: ACTOR) 53 | elif url == NOTE_ID: 54 | return MagicMock(status_code=200, json=lambda: NOTE) 55 | elif url == ACTOR2_ID: 56 | return MagicMock(status_code=200, json=lambda: ACTOR2) 57 | elif url == NOTE2_ID: 58 | return MagicMock(status_code=200, json=lambda: NOTE2) 59 | else: 60 | return MagicMock(status_code=404) 61 | 62 | 63 | def mock_requests_get(url, **kwargs): 64 | if url == WEBFINGER_URL_BASE: 65 | return MagicMock( 66 | status_code=200, 67 | headers={"Content-Type": "application/jrd+json"}, 68 | json=lambda: ACTOR_WEBFINGER_JSON, 69 | ) 70 | else: 71 | return MagicMock(status_code=404) 72 | 73 | 74 | class TestGetCommand(unittest.TestCase): 75 | def setUp(self): 76 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 77 | self.log_stream = io.StringIO() 78 | logging.basicConfig(level=logging.DEBUG, stream=self.log_stream) 79 | 80 | def tearDown(self): 81 | sys.stdout = self.held 82 | # print(self.log_stream.getvalue()) 83 | 84 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 85 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 86 | def test_get_note(self, mock_requests_get, mock_file): 87 | run_command(["get", NOTE_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 88 | 89 | # Assertions 90 | mock_requests_get.assert_called_once() 91 | for headers in request_headers: 92 | self.assertIn("User-Agent", headers) 93 | self.assertRegex(headers["User-Agent"], USER_AGENT) 94 | self.assertIn(NOTE["content"], sys.stdout.getvalue()) 95 | 96 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 97 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 98 | def test_get_note_with_at_symbol(self, mock_requests_get, mock_file): 99 | run_command(["get", NOTE2_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 100 | 101 | # Assertions 102 | mock_requests_get.assert_called_once() 103 | self.assertIn(NOTE2["content"], sys.stdout.getvalue()) 104 | 105 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 106 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 107 | def test_get_actor(self, mock_requests_get, mock_file): 108 | run_command(["get", ACTOR_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 109 | 110 | # Assertions 111 | mock_requests_get.assert_called_once() 112 | for headers in request_headers: 113 | self.assertIn("User-Agent", headers) 114 | self.assertRegex(headers["User-Agent"], USER_AGENT) 115 | self.assertIn(ACTOR['type'], sys.stdout.getvalue()) 116 | 117 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 118 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 119 | @patch('requests.get', side_effect=mock_requests_get) 120 | def test_get_webfinger(self, mock_requests_get, mock_oauth_get, mock_file): 121 | run_command(["get", ACTOR_WEBFINGER_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 122 | 123 | # Assertions 124 | mock_oauth_get.assert_called_once() 125 | mock_requests_get.assert_called_once() 126 | for headers in request_headers: 127 | self.assertIn("User-Agent", headers) 128 | self.assertRegex(headers["User-Agent"], USER_AGENT) 129 | self.assertIn(ACTOR_ID, sys.stdout.getvalue()) 130 | 131 | 132 | if __name__ == "__main__": 133 | unittest.main() 134 | -------------------------------------------------------------------------------- /tests/test_inbox.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, Mock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests.models import Response 9 | from requests_oauthlib import OAuth2Session 10 | import json 11 | 12 | ACTOR_ID = "https://social.example/users/evanp" 13 | OTHER_ID = "https://different.example/users/other" 14 | INBOX_ID = f"{ACTOR_ID}/inbox" 15 | PAGE_2_ID = f"{INBOX_ID}/page/2" 16 | PAGE_1_ID = f"{INBOX_ID}/page/1" 17 | OTHER_ACTIVITY_ID = f"{OTHER_ID}/activity/11" 18 | ACTIVITY_ID = f"{ACTOR_ID}/activity/10" 19 | INBOX = {"id": INBOX_ID, "attributedTo": ACTOR_ID, "first": PAGE_2_ID} 20 | 21 | ACTOR = { 22 | "type": "Person", 23 | "id": ACTOR_ID, 24 | "outbox": f"{ACTOR_ID}/outbox", 25 | "inbox": INBOX_ID, 26 | "preferredUsername": "evanp", 27 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 28 | } 29 | 30 | OTHER = { 31 | "type": "Person", 32 | "id": OTHER_ID, 33 | "outbox": f"{OTHER_ID}/outbox", 34 | "inbox": f"{OTHER_ID}/inbox", 35 | "preferredUsername": "other", 36 | } 37 | 38 | PAGE_2 = { 39 | "id": PAGE_2_ID, 40 | "partOf": INBOX_ID, 41 | "next": PAGE_1_ID, 42 | "orderedItems": [ 43 | { 44 | "type": "Activity", 45 | "id": OTHER_ACTIVITY_ID, 46 | "summary": "Page 2 Activity 11", 47 | } 48 | ], 49 | } 50 | 51 | PAGE_1 = { 52 | "id": PAGE_1_ID, 53 | "partOf": INBOX_ID, 54 | "prev": PAGE_2_ID, 55 | "orderedItems": [ 56 | { 57 | "type": "Activity", 58 | "id": ACTIVITY_ID, 59 | "summary": "Page 1 Activity 10", 60 | } 61 | ], 62 | } 63 | 64 | ACTIVITY = { 65 | "id": ACTIVITY_ID, 66 | "actor": ACTOR_ID, 67 | "type": "Activity", 68 | "summary": "Page 1 Activity 10", 69 | "published": "2021-01-01T00:00:00Z", 70 | } 71 | 72 | OTHER_ACTIVITY = { 73 | "actor": OTHER_ID, 74 | "type": "Activity", 75 | "id": f"{OTHER_ID}/activity/11", 76 | "summary": "Page 2 Activity 11", 77 | "published": "2021-01-01T00:00:00Z", 78 | } 79 | 80 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 81 | 82 | def mock_response(url, obj): 83 | r = Response() 84 | r.url = url 85 | r.status_code = 200 86 | r.reason = "OK" 87 | r._content = json.dumps(obj).encode("utf-8") 88 | r.headers["Content-Type"] = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" 89 | return r 90 | 91 | def mock_404(url): 92 | r = Response() 93 | r.status_code = 404 94 | r.url = url 95 | r.reason = "Not found" 96 | r._content = b"Not found" 97 | r.headers["Content-Type"] = "text/plain" 98 | return r 99 | 100 | def mock_oauth_get(url, headers=None): 101 | if url == ACTOR_ID: 102 | return mock_response(ACTOR_ID, ACTOR) 103 | elif url == INBOX_ID: 104 | return mock_response(INBOX_ID, INBOX) 105 | elif url == PAGE_2_ID: 106 | return mock_response(PAGE_2_ID, PAGE_2) 107 | elif url == PAGE_1_ID: 108 | return mock_response(PAGE_1_ID, PAGE_1) 109 | elif url == ACTIVITY_ID: 110 | return mock_response(ACTIVITY_ID, ACTIVITY) 111 | else: 112 | return mock_404() 113 | 114 | 115 | def mock_oauth_post(url, headers=None, data=None): 116 | if url == ACTOR["endpoints"]["proxyUrl"]: 117 | if data["id"] == OTHER_ID: 118 | return mock_response(OTHER_ID, OTHER) 119 | elif data["id"] == OTHER_ACTIVITY_ID: 120 | return mock_response(OTHER_ACTIVITY_ID, OTHER_ACTIVITY) 121 | else: 122 | return mock_404(data["id"]) 123 | else: 124 | return mock_404(url) 125 | 126 | 127 | class TestInboxCommand(unittest.TestCase): 128 | def setUp(self): 129 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 130 | 131 | def tearDown(self): 132 | sys.stdout = self.held 133 | 134 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 135 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 136 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 137 | def test_inbox(self, mock_requests_post, mock_requests_get, mock_file): 138 | run_command(["inbox"], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 139 | 140 | # Assertions 141 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 142 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 143 | self.assertIn("Page 1 Activity 10", sys.stdout.getvalue()) 144 | self.assertIn("evanp@social.example", sys.stdout.getvalue()) 145 | self.assertIn("other@different.example", sys.stdout.getvalue()) 146 | 147 | 148 | if __name__ == "__main__": 149 | unittest.main() 150 | -------------------------------------------------------------------------------- /tests/test_like.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | OBJECT_ID = f"{OTHER_ID}/objects/1" 14 | LIKES_ID = f"{OBJECT_ID}/likes" 15 | REMOTE_ID = "https://remote.example/users/other" 16 | REMOTE_OBJECT_ID = f"{REMOTE_ID}/objects/1" 17 | REMOTE_LIKES_ID = f"{REMOTE_OBJECT_ID}/likes" 18 | LIKE_ID = f"{ACTOR_ID}/likes/1" 19 | 20 | ACTOR = { 21 | "type": "Person", 22 | "id": ACTOR_ID, 23 | "outbox": f"{ACTOR_ID}/outbox", 24 | "preferredUsername": "evanp", 25 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 26 | } 27 | 28 | OTHER = { 29 | "type": "Person", 30 | "id": OTHER_ID, 31 | "preferredUsername": "other", 32 | } 33 | 34 | OBJECT = { 35 | "type": "Note", 36 | "id": OBJECT_ID, 37 | "likes": LIKES_ID, 38 | "attributedTo": OTHER_ID, 39 | "content": "This is a note", 40 | } 41 | 42 | LIKES = { 43 | "id": LIKES_ID, 44 | "attributedTo": OTHER_ID, 45 | "type": "Collection", 46 | "totalItems": 0, 47 | "items": [ 48 | ] 49 | } 50 | 51 | REMOTE = { 52 | "type": "Person", 53 | "id": REMOTE_ID, 54 | "preferredUsername": "other", 55 | } 56 | 57 | REMOTE_OBJECT = { 58 | "type": "Object", 59 | "id": REMOTE_OBJECT_ID, 60 | "attributedTo": REMOTE_ID, 61 | "likes": REMOTE_LIKES_ID, 62 | } 63 | 64 | REMOTE_LIKES = { 65 | "id": REMOTE_LIKES_ID, 66 | "attributedTo": REMOTE_ID, 67 | "type": "Collection", 68 | "totalItems": 0, 69 | "items": [ 70 | ] 71 | } 72 | 73 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 74 | 75 | def mock_oauth_get(url, headers=None): 76 | if url == ACTOR_ID: 77 | return MagicMock(status_code=200, json=lambda: ACTOR) 78 | elif url == OTHER_ID: 79 | return MagicMock(status_code=200, json=lambda: OTHER) 80 | elif url == OBJECT_ID: 81 | return MagicMock(status_code=200, json=lambda: OBJECT) 82 | elif url == LIKES_ID: 83 | return MagicMock(status_code=200, json=lambda: LIKES) 84 | else: 85 | return MagicMock(status_code=404) 86 | 87 | 88 | def mock_oauth_post(url, headers=None, data=None): 89 | if url == ACTOR["endpoints"]["proxyUrl"]: 90 | if data["id"] == REMOTE_ID: 91 | return MagicMock(status_code=200, json=lambda: REMOTE) 92 | if data["id"] == REMOTE_OBJECT_ID: 93 | return MagicMock(status_code=200, json=lambda: REMOTE_OBJECT) 94 | if data["id"] == REMOTE_LIKES_ID: 95 | return MagicMock(status_code=200, json=lambda: REMOTE_LIKES) 96 | else: 97 | return MagicMock(status_code=404) 98 | elif url == ACTOR["outbox"]: 99 | data = json.loads(data) 100 | added_data = { 101 | "id": LIKE_ID, 102 | "actor": ACTOR_ID, 103 | "published": "2020-01-01T00:00:00Z", 104 | } 105 | result = {**data, **added_data} 106 | return MagicMock(status_code=200, json=lambda: result) 107 | else: 108 | return MagicMock(status_code=404) 109 | 110 | 111 | class TestLikesCommand(unittest.TestCase): 112 | def setUp(self): 113 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 114 | 115 | def tearDown(self): 116 | sys.stdout = self.held 117 | 118 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 119 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 120 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 121 | def test_like_local(self, mock_requests_post, mock_requests_get, mock_file): 122 | run_command(["like", OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 123 | 124 | # Assertions 125 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 126 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 127 | output = sys.stdout.getvalue() 128 | self.assertIn(LIKE_ID, output) 129 | 130 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 131 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 132 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 133 | def test_likes_remote(self, mock_requests_post, mock_requests_get, mock_file): 134 | run_command(["like", REMOTE_OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 135 | 136 | # Assertions 137 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 138 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 139 | output = sys.stdout.getvalue() 140 | self.assertIn(LIKE_ID, output) 141 | 142 | 143 | if __name__ == "__main__": 144 | unittest.main() 145 | -------------------------------------------------------------------------------- /tests/test_likes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | OBJECT_ID = f"{OTHER_ID}/objects/1" 14 | LIKES_ID = f"{OBJECT_ID}/likes" 15 | REMOTE_ID = "https://remote.example/users/other" 16 | REMOTE_OBJECT_ID = f"{REMOTE_ID}/objects/1" 17 | REMOTE_LIKES_ID = f"{REMOTE_OBJECT_ID}/likes" 18 | 19 | ANOTHER_ACTOR_ID = "https://social.example/users/another" 20 | AND_ANOTHER_ACTOR_ID = "https://social.example/users/andanother" 21 | 22 | LIKE_1_ID = f"{ANOTHER_ACTOR_ID}/likes/1" 23 | LIKE_2_ID = f"{AND_ANOTHER_ACTOR_ID}/likes/2" 24 | LIKE_3_ID = f"{ACTOR_ID}/likes/3" 25 | LIKE_4_ID = f"{OTHER_ID}/likes/4" 26 | LIKE_5_ID = f"{REMOTE_ID}/likes/5" 27 | 28 | ACTOR = { 29 | "type": "Person", 30 | "id": ACTOR_ID, 31 | "outbox": f"{ACTOR_ID}/outbox", 32 | "preferredUsername": "evanp", 33 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 34 | } 35 | 36 | OTHER = { 37 | "type": "Person", 38 | "id": OTHER_ID, 39 | "preferredUsername": "other", 40 | } 41 | 42 | OBJECT = { 43 | "type": "Note", 44 | "id": OBJECT_ID, 45 | "likes": LIKES_ID, 46 | "attributedTo": OTHER_ID, 47 | "content": "This is a note", 48 | } 49 | 50 | ANOTHER_ACTOR = { 51 | "type": "Person", 52 | "id": ANOTHER_ACTOR_ID, 53 | "preferredUsername": "another", 54 | } 55 | 56 | AND_ANOTHER_ACTOR = { 57 | "type": "Person", 58 | "id": AND_ANOTHER_ACTOR_ID, 59 | "preferredUsername": "andanother", 60 | } 61 | 62 | LIKE_1 = { 63 | "type": "Like", 64 | "id": LIKE_1_ID, 65 | "object": OBJECT_ID, 66 | "actor": ANOTHER_ACTOR_ID, 67 | "published": "2020-01-01T00:00:00Z", 68 | } 69 | 70 | LIKE_2 = { 71 | "type": "Like", 72 | "id": LIKE_2_ID, 73 | "object": REMOTE_OBJECT_ID, 74 | "actor": AND_ANOTHER_ACTOR_ID, 75 | "published": "2020-01-01T00:00:00Z", 76 | } 77 | 78 | LIKE_3 = { 79 | "type": "Like", 80 | "id": LIKE_3_ID, 81 | "object": OBJECT_ID, 82 | "actor": ACTOR_ID, 83 | "published": "2020-01-01T00:00:00Z", 84 | } 85 | 86 | LIKE_4 = { 87 | "type": "Like", 88 | "id": LIKE_4_ID, 89 | "object": REMOTE_OBJECT_ID, 90 | "actor": OTHER_ID, 91 | "published": "2020-01-01T00:00:00Z", 92 | } 93 | 94 | LIKE_5 = { 95 | "type": "Like", 96 | "id": LIKE_5_ID, 97 | "object": OBJECT_ID, 98 | "actor": REMOTE_ID, 99 | "published": "2020-01-01T00:00:00Z", 100 | } 101 | 102 | LIKES = { 103 | "id": LIKES_ID, 104 | "attributedTo": OTHER_ID, 105 | "type": "Collection", 106 | "totalItems": 3, 107 | "items": [ 108 | LIKE_1_ID, 109 | LIKE_3_ID, 110 | LIKE_5_ID, 111 | ] 112 | } 113 | 114 | REMOTE = { 115 | "type": "Person", 116 | "id": REMOTE_ID, 117 | "preferredUsername": "other", 118 | } 119 | 120 | REMOTE_OBJECT = { 121 | "type": "Object", 122 | "id": REMOTE_OBJECT_ID, 123 | "attributedTo": REMOTE_ID, 124 | "likes": REMOTE_LIKES_ID, 125 | } 126 | 127 | REMOTE_LIKES = { 128 | "id": REMOTE_LIKES_ID, 129 | "attributedTo": REMOTE_ID, 130 | "type": "Collection", 131 | "totalItems": 2, 132 | "items": [ 133 | LIKE_2_ID, 134 | LIKE_4_ID, 135 | ] 136 | } 137 | 138 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 139 | 140 | def mock_oauth_get(url, headers=None): 141 | if url == ACTOR_ID: 142 | return MagicMock(status_code=200, json=lambda: ACTOR) 143 | elif url == OTHER_ID: 144 | return MagicMock(status_code=200, json=lambda: OTHER) 145 | elif url == OBJECT_ID: 146 | return MagicMock(status_code=200, json=lambda: OBJECT) 147 | elif url == LIKES_ID: 148 | return MagicMock(status_code=200, json=lambda: LIKES) 149 | elif url == LIKE_1_ID: 150 | return MagicMock(status_code=200, json=lambda: LIKE_1) 151 | elif url == LIKE_2_ID: 152 | return MagicMock(status_code=200, json=lambda: LIKE_2) 153 | elif url == LIKE_3_ID: 154 | return MagicMock(status_code=200, json=lambda: LIKE_3) 155 | elif url == LIKE_4_ID: 156 | return MagicMock(status_code=200, json=lambda: LIKE_4) 157 | elif url == ANOTHER_ACTOR_ID: 158 | return MagicMock(status_code=200, json=lambda: ANOTHER_ACTOR) 159 | elif url == AND_ANOTHER_ACTOR_ID: 160 | return MagicMock(status_code=200, json=lambda: AND_ANOTHER_ACTOR) 161 | else: 162 | return MagicMock(status_code=404) 163 | 164 | 165 | def mock_oauth_post(url, headers=None, data=None): 166 | if url == ACTOR["endpoints"]["proxyUrl"]: 167 | if data["id"] == REMOTE_ID: 168 | return MagicMock(status_code=200, json=lambda: REMOTE) 169 | if data["id"] == REMOTE_OBJECT_ID: 170 | return MagicMock(status_code=200, json=lambda: REMOTE_OBJECT) 171 | if data["id"] == REMOTE_LIKES_ID: 172 | return MagicMock(status_code=200, json=lambda: REMOTE_LIKES) 173 | if data["id"] == LIKE_5_ID: 174 | return MagicMock(status_code=200, json=lambda: LIKE_5) 175 | else: 176 | return MagicMock(status_code=404) 177 | else: 178 | return MagicMock(status_code=404) 179 | 180 | 181 | class TestLikesCommand(unittest.TestCase): 182 | def setUp(self): 183 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 184 | 185 | def tearDown(self): 186 | sys.stdout = self.held 187 | 188 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 189 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 190 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 191 | def test_likes_local(self, mock_requests_post, mock_requests_get, mock_file): 192 | run_command(["likes", OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 193 | 194 | # Assertions 195 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 196 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 197 | output = sys.stdout.getvalue() 198 | self.assertIn(LIKE_1_ID, output) 199 | self.assertIn(LIKE_3_ID, output) 200 | self.assertIn(LIKE_5_ID, output) 201 | self.assertNotIn(LIKE_2_ID, output) 202 | self.assertNotIn(LIKE_4_ID, output) 203 | 204 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 205 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 206 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 207 | def test_likes_remote(self, mock_requests_post, mock_requests_get, mock_file): 208 | run_command(["likes", REMOTE_OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 209 | 210 | # Assertions 211 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 212 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 213 | output = sys.stdout.getvalue() 214 | self.assertNotIn(LIKE_1_ID, output) 215 | self.assertNotIn(LIKE_3_ID, output) 216 | self.assertNotIn(LIKE_5_ID, output) 217 | self.assertIn(LIKE_2_ID, output) 218 | self.assertIn(LIKE_4_ID, output) 219 | 220 | 221 | if __name__ == "__main__": 222 | unittest.main() 223 | -------------------------------------------------------------------------------- /tests/test_login.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | from ap.version import __version__ 11 | import logging 12 | import webbrowser 13 | import threading 14 | import time 15 | import urllib.request 16 | from urllib.parse import urlparse, parse_qs, urlencode 17 | from pathlib import Path 18 | 19 | USER_AGENT = f"ap/{__version__}" 20 | 21 | AUTHORIZATION_ENDPOINT = "https://social.example/oauth/authorization" 22 | TOKEN_ENDPOINT = "https://social.example/oauth/token" 23 | 24 | ACTOR_ID = "https://social.example/users/evanp" 25 | ACTOR = { 26 | "type": "Person", 27 | "id": ACTOR_ID, 28 | "outbox": "https://social.example/users/evanp/outbox", 29 | "endpoints": { 30 | "oauthAuthorizationEndpoint": AUTHORIZATION_ENDPOINT, 31 | "oauthTokenEndpoint": TOKEN_ENDPOINT 32 | } 33 | } 34 | 35 | ACTOR_WEBFINGER_ID = "evanp@social.example" 36 | 37 | WEBFINGER_URL_BASE = "https://social.example/.well-known/webfinger" 38 | ACTOR_WEBFINGER_URL = WEBFINGER_URL_BASE + "?resource=acct%3Aevanp%40social.example" 39 | 40 | ACTOR_WEBFINGER_JSON = { 41 | "subject": "acct:evanp@social.example", 42 | "links": [{"rel": "self", 43 | "type": "application/activity+json", 44 | "href": ACTOR_ID}] 45 | } 46 | 47 | AUTHORIZATION_CODE = '1234567890ABCDEF' 48 | ACCESS_TOKEN = { 49 | "access_token": 'XYZPDQ', 50 | "token_type": 'Bearer', 51 | "scope": 'read write', 52 | "expires_in": 86400, 53 | "refresh_token": 'OMGBBQ' 54 | } 55 | 56 | def mock_requests_get(url, **kwargs): 57 | if url == WEBFINGER_URL_BASE: 58 | return MagicMock( 59 | status_code=200, 60 | headers={"Content-Type": "application/jrd+json"}, 61 | json=lambda: ACTOR_WEBFINGER_JSON, 62 | ) 63 | elif url == ACTOR_ID: 64 | return MagicMock(status_code=200, 65 | headers={"Content-Type": "application/activity+json"}, 66 | json=lambda: ACTOR 67 | ) 68 | else: 69 | return MagicMock(status_code=404) 70 | 71 | def mock_oauth_request(url, **kwargs): 72 | if url == TOKEN_ENDPOINT: 73 | return MagicMock( 74 | status_code=200, 75 | headers={"Content-Type": "application/json"}, 76 | text=json.dumps(ACCESS_TOKEN) 77 | ) 78 | else: 79 | return MagicMock(status_code=404) 80 | 81 | authorization_params = None 82 | web_response = None 83 | 84 | def mock_webbrowser_open(url): 85 | parsed = urlparse(url) 86 | base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" 87 | if base_url == AUTHORIZATION_ENDPOINT: 88 | global authorization_params 89 | authorization_params = parse_qs(parsed.query) 90 | redirect_uri = authorization_params["redirect_uri"][0] 91 | def delayed_callback(): 92 | time.sleep(0.1) 93 | params = { 94 | "code": AUTHORIZATION_CODE, 95 | "state": authorization_params['state'][0] 96 | } 97 | uri = redirect_uri + "?" + urlencode(params) 98 | with urllib.request.urlopen(uri) as response: 99 | global web_response 100 | web_response = response.read() 101 | threading.Thread(target=delayed_callback).start() 102 | 103 | class TestLoginCommand(unittest.TestCase): 104 | def setUp(self): 105 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 106 | 107 | def tearDown(self): 108 | sys.stdout = self.held 109 | 110 | @patch.object(Path, 'mkdir') 111 | @patch("builtins.open", new_callable=mock_open) 112 | @patch('webbrowser.open', side_effect=mock_webbrowser_open) 113 | @patch('requests.get', side_effect=mock_requests_get) 114 | @patch("requests_oauthlib.OAuth2Session.request", side_effect=mock_oauth_request) 115 | def test_login(self, mock_requests_post, mock_requests_get, mock_webbrowser_open, mock_file, mock_path_mkdir): 116 | run_command(["login", ACTOR_WEBFINGER_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 117 | 118 | # Assertions 119 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 120 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 121 | mock_webbrowser_open.assert_called_once() 122 | 123 | global authorization_params 124 | assert "client_id" in authorization_params 125 | 126 | mock_file.assert_called_once_with(Path('/home/notauser/.ap/token.json'), 'w') 127 | -------------------------------------------------------------------------------- /tests/test_outbox.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OUTBOX_ID = f"{ACTOR_ID}/outbox" 13 | PAGE_2_ID = f"{OUTBOX_ID}/page/2" 14 | PAGE_1_ID = f"{OUTBOX_ID}/page/1" 15 | 16 | OUTBOX = {"id": OUTBOX_ID, "attributedTo": ACTOR_ID, "first": PAGE_2_ID} 17 | 18 | ACTOR = { 19 | "type": "Person", 20 | "id": ACTOR_ID, 21 | "outbox": OUTBOX, 22 | "inbox": f"{ACTOR_ID}/inbox", 23 | "preferredUsername": "evanp", 24 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 25 | } 26 | 27 | PAGE_2 = { 28 | "id": PAGE_2_ID, 29 | "partOf": OUTBOX_ID, 30 | "next": PAGE_1_ID, 31 | "orderedItems": [ 32 | { 33 | "type": "Activity", 34 | "id": f"{ACTOR_ID}/activity/11", 35 | "summary": "Page 2 Activity 11", 36 | } 37 | ], 38 | } 39 | 40 | PAGE_1 = { 41 | "id": PAGE_1_ID, 42 | "partOf": OUTBOX_ID, 43 | "prev": PAGE_2_ID, 44 | "orderedItems": [ 45 | { 46 | "type": "Activity", 47 | "id": f"{ACTOR_ID}/activity/10", 48 | "summary": "Page 1 Activity 10", 49 | } 50 | ], 51 | } 52 | 53 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 54 | 55 | 56 | def mock_oauth_get(url, headers=None): 57 | if url == ACTOR_ID: 58 | return MagicMock(status_code=200, json=lambda: ACTOR) 59 | elif url == OUTBOX_ID: 60 | return MagicMock(status_code=200, json=lambda: OUTBOX) 61 | elif url == PAGE_2_ID: 62 | return MagicMock(status_code=200, json=lambda: PAGE_2) 63 | elif url == PAGE_1_ID: 64 | return MagicMock(status_code=200, json=lambda: PAGE_1) 65 | else: 66 | return MagicMock(status_code=404) 67 | 68 | 69 | class TestOutboxCommand(unittest.TestCase): 70 | def setUp(self): 71 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 72 | 73 | def tearDown(self): 74 | sys.stdout = self.held 75 | 76 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 77 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 78 | def test_outbox(self, mock_requests_get, mock_file): 79 | run_command(["outbox"], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 80 | 81 | # Assertions 82 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 83 | self.assertIn("Page 1 Activity 10", sys.stdout.getvalue()) 84 | 85 | 86 | if __name__ == "__main__": 87 | unittest.main() 88 | -------------------------------------------------------------------------------- /tests/test_pending_followers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_1_ID = "https://different.example/users/other1" 13 | OTHER_2_ID = "https://social.example/users/other2" 14 | PENDING_FOLLOWERS_ID = f"{ACTOR_ID}/followers/pending" 15 | PAGE_2_ID = f"{PENDING_FOLLOWERS_ID}/page/2" 16 | PAGE_1_ID = f"{PENDING_FOLLOWERS_ID}/page/1" 17 | FOLLOW_1_ID = f"{OTHER_1_ID}/follows/1" 18 | FOLLOW_2_ID = f"{OTHER_2_ID}/follows/2" 19 | 20 | ACTOR = { 21 | "type": "Person", 22 | "id": ACTOR_ID, 23 | "outbox": f"{ACTOR_ID}/outbox", 24 | "inbox": f"{ACTOR_ID}/outbox", 25 | "pendingFollowers": PENDING_FOLLOWERS_ID, 26 | "preferredUsername": "evanp", 27 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 28 | } 29 | 30 | OTHER_1 = { 31 | "type": "Person", 32 | "id": OTHER_1_ID, 33 | "outbox": f"{OTHER_1_ID}/outbox", 34 | "inbox": f"{OTHER_1_ID}/inbox", 35 | "preferredUsername": "other1", 36 | } 37 | 38 | OTHER_2 = { 39 | "type": "Person", 40 | "id": OTHER_2_ID, 41 | "outbox": f"{OTHER_2_ID}/outbox", 42 | "inbox": f"{OTHER_2_ID}/inbox", 43 | "preferredUsername": "other2", 44 | } 45 | 46 | PENDING_FOLLOWERS = { 47 | "id": PENDING_FOLLOWERS_ID, 48 | "attributedTo": ACTOR_ID, 49 | "first": PAGE_2_ID, 50 | } 51 | 52 | FOLLOW_1 = { 53 | "id": FOLLOW_1_ID, 54 | "actor": OTHER_1_ID, 55 | "type": "Follow", 56 | "object": ACTOR_ID, 57 | "published": "2020-01-01T00:00:00Z", 58 | } 59 | 60 | FOLLOW_2 = { 61 | "id": FOLLOW_2_ID, 62 | "actor": OTHER_2_ID, 63 | "type": "Follow", 64 | "object": ACTOR_ID, 65 | "published": "2020-01-01T00:00:00Z", 66 | } 67 | 68 | PAGE_2 = { 69 | "id": PAGE_2_ID, 70 | "partOf": PENDING_FOLLOWERS_ID, 71 | "next": PAGE_1_ID, 72 | "orderedItems": [FOLLOW_1], 73 | } 74 | 75 | PAGE_1 = { 76 | "id": PAGE_1_ID, 77 | "partOf": PENDING_FOLLOWERS_ID, 78 | "prev": PAGE_2_ID, 79 | "orderedItems": [FOLLOW_2], 80 | } 81 | 82 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 83 | 84 | 85 | def mock_oauth_get(url, headers=None): 86 | if url == ACTOR_ID: 87 | return MagicMock(status_code=200, json=lambda: ACTOR) 88 | elif url == PENDING_FOLLOWERS_ID: 89 | return MagicMock(status_code=200, json=lambda: PENDING_FOLLOWERS) 90 | elif url == PAGE_2_ID: 91 | return MagicMock(status_code=200, json=lambda: PAGE_2) 92 | elif url == PAGE_1_ID: 93 | return MagicMock(status_code=200, json=lambda: PAGE_1) 94 | elif url == OTHER_2_ID: 95 | return MagicMock(status_code=200, json=lambda: OTHER_2) 96 | elif url == FOLLOW_2_ID: 97 | return MagicMock(status_code=200, json=lambda: FOLLOW_2) 98 | else: 99 | return MagicMock(status_code=404) 100 | 101 | 102 | def mock_oauth_post(url, headers=None, data=None): 103 | if url == ACTOR["endpoints"]["proxyUrl"]: 104 | if data["id"] == OTHER_1_ID: 105 | return MagicMock(status_code=200, json=lambda: OTHER_1) 106 | if data["id"] == FOLLOW_1_ID: 107 | return MagicMock(status_code=200, json=lambda: FOLLOW_1) 108 | else: 109 | return MagicMock(status_code=404) 110 | else: 111 | return MagicMock(status_code=404) 112 | 113 | 114 | class TestPendingFollowersCommand(unittest.TestCase): 115 | def setUp(self): 116 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 117 | 118 | def tearDown(self): 119 | sys.stdout = self.held 120 | 121 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 122 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 123 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 124 | def test_pending_followers(self, mock_requests_post, mock_requests_get, mock_file): 125 | run_command(["pending", "followers"], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 126 | 127 | # Assertions 128 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 129 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 130 | self.assertIn("other2@social.example", sys.stdout.getvalue()) 131 | self.assertIn("other1@different.example", sys.stdout.getvalue()) 132 | 133 | 134 | if __name__ == "__main__": 135 | unittest.main() 136 | -------------------------------------------------------------------------------- /tests/test_pending_following.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_1_ID = "https://different.example/users/other1" 13 | OTHER_2_ID = "https://social.example/users/other2" 14 | PENDING_FOLLOWING_ID = f"{ACTOR_ID}/following/pending" 15 | PAGE_2_ID = f"{PENDING_FOLLOWING_ID}/page/2" 16 | PAGE_1_ID = f"{PENDING_FOLLOWING_ID}/page/1" 17 | FOLLOW_1_ID = f"{OTHER_1_ID}/follows/1" 18 | FOLLOW_2_ID = f"{OTHER_2_ID}/follows/2" 19 | 20 | ACTOR = { 21 | "type": "Person", 22 | "id": ACTOR_ID, 23 | "outbox": f"{ACTOR_ID}/outbox", 24 | "inbox": f"{ACTOR_ID}/outbox", 25 | "pendingFollowing": PENDING_FOLLOWING_ID, 26 | "preferredUsername": "evanp", 27 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 28 | } 29 | 30 | OTHER_1 = { 31 | "type": "Person", 32 | "id": OTHER_1_ID, 33 | "outbox": f"{OTHER_1_ID}/outbox", 34 | "inbox": f"{OTHER_1_ID}/inbox", 35 | "preferredUsername": "other1", 36 | } 37 | 38 | OTHER_2 = { 39 | "type": "Person", 40 | "id": OTHER_2_ID, 41 | "outbox": f"{OTHER_2_ID}/outbox", 42 | "inbox": f"{OTHER_2_ID}/inbox", 43 | "preferredUsername": "other2", 44 | } 45 | 46 | PENDING_FOLLOWING = { 47 | "id": PENDING_FOLLOWING_ID, 48 | "attributedTo": ACTOR_ID, 49 | "first": PAGE_2_ID, 50 | } 51 | 52 | FOLLOW_1 = { 53 | "id": FOLLOW_1_ID, 54 | "actor": ACTOR_ID, 55 | "type": "Follow", 56 | "object": OTHER_1_ID, 57 | "published": "2020-01-01T00:00:00Z", 58 | } 59 | 60 | FOLLOW_2 = { 61 | "id": FOLLOW_2_ID, 62 | "actor": ACTOR_ID, 63 | "type": "Follow", 64 | "object": OTHER_2_ID, 65 | "published": "2020-01-01T00:00:00Z", 66 | } 67 | 68 | PAGE_2 = { 69 | "id": PAGE_2_ID, 70 | "partOf": PENDING_FOLLOWING_ID, 71 | "next": PAGE_1_ID, 72 | "orderedItems": [FOLLOW_1], 73 | } 74 | 75 | PAGE_1 = { 76 | "id": PAGE_1_ID, 77 | "partOf": PENDING_FOLLOWING_ID, 78 | "prev": PAGE_2_ID, 79 | "orderedItems": [FOLLOW_2], 80 | } 81 | 82 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 83 | 84 | 85 | def mock_oauth_get(url, headers=None): 86 | if url == ACTOR_ID: 87 | return MagicMock(status_code=200, json=lambda: ACTOR) 88 | elif url == PENDING_FOLLOWING_ID: 89 | return MagicMock(status_code=200, json=lambda: PENDING_FOLLOWING) 90 | elif url == PAGE_2_ID: 91 | return MagicMock(status_code=200, json=lambda: PAGE_2) 92 | elif url == PAGE_1_ID: 93 | return MagicMock(status_code=200, json=lambda: PAGE_1) 94 | elif url == OTHER_2_ID: 95 | return MagicMock(status_code=200, json=lambda: OTHER_2) 96 | elif url == FOLLOW_2_ID: 97 | return MagicMock(status_code=200, json=lambda: FOLLOW_2) 98 | else: 99 | return MagicMock(status_code=404) 100 | 101 | 102 | def mock_oauth_post(url, headers=None, data=None): 103 | if url == ACTOR["endpoints"]["proxyUrl"]: 104 | if data["id"] == OTHER_1_ID: 105 | return MagicMock(status_code=200, json=lambda: OTHER_1) 106 | if data["id"] == FOLLOW_1_ID: 107 | return MagicMock(status_code=200, json=lambda: FOLLOW_1) 108 | else: 109 | return MagicMock(status_code=404) 110 | else: 111 | return MagicMock(status_code=404) 112 | 113 | 114 | class TestPendingFollowingCommand(unittest.TestCase): 115 | def setUp(self): 116 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 117 | 118 | def tearDown(self): 119 | sys.stdout = self.held 120 | 121 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 122 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 123 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 124 | def test_pending_following(self, mock_requests_post, mock_requests_get, mock_file): 125 | run_command(["pending", "following"], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 126 | 127 | # Assertions 128 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 129 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 130 | self.assertIn("other2@social.example", sys.stdout.getvalue()) 131 | self.assertIn("other1@different.example", sys.stdout.getvalue()) 132 | 133 | 134 | if __name__ == "__main__": 135 | unittest.main() 136 | -------------------------------------------------------------------------------- /tests/test_reject_follower.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_1_ID = "https://different.example/users/other1" 13 | OTHER_2_ID = "https://social.example/users/other2" 14 | REJECT_ID = f"{ACTOR_ID}/reject/1" 15 | PENDING_FOLLOWERS_ID = f"{ACTOR_ID}/pending/followers" 16 | PAGE_2_ID = f"{PENDING_FOLLOWERS_ID}/page/2" 17 | PAGE_1_ID = f"{PENDING_FOLLOWERS_ID}/page/1" 18 | FOLLOW_1_ID = f"{OTHER_1_ID}/follows/1" 19 | FOLLOW_2_ID = f"{OTHER_2_ID}/follows/2" 20 | 21 | ACTOR = { 22 | "type": "Person", 23 | "id": ACTOR_ID, 24 | "outbox": f"{ACTOR_ID}/outbox", 25 | "pendingFollowers": PENDING_FOLLOWERS_ID, 26 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 27 | } 28 | 29 | OTHER_1 = { 30 | "type": "Person", 31 | "id": OTHER_1_ID, 32 | "preferredUsername": "other1", 33 | "inbox": f"{OTHER_1_ID}/inbox", 34 | } 35 | 36 | OTHER_2 = { 37 | "type": "Person", 38 | "id": OTHER_1_ID, 39 | "preferredUsername": "other2", 40 | "inbox": f"{OTHER_2_ID}/inbox", 41 | } 42 | 43 | 44 | PENDING_FOLLOWERS = { 45 | "id": PENDING_FOLLOWERS_ID, 46 | "attributedTo": ACTOR_ID, 47 | "first": PAGE_2_ID, 48 | } 49 | 50 | FOLLOW_1 = { 51 | "id": FOLLOW_1_ID, 52 | "actor": OTHER_1_ID, 53 | "type": "Follow", 54 | "object": ACTOR_ID, 55 | "published": "2020-01-01T00:00:00Z", 56 | } 57 | 58 | FOLLOW_2 = { 59 | "id": FOLLOW_2_ID, 60 | "actor": OTHER_2_ID, 61 | "type": "Follow", 62 | "object": ACTOR_ID, 63 | "published": "2020-01-01T00:00:00Z", 64 | } 65 | 66 | PAGE_2 = { 67 | "id": PAGE_2_ID, 68 | "partOf": PENDING_FOLLOWERS_ID, 69 | "next": PAGE_1_ID, 70 | "orderedItems": [FOLLOW_1], 71 | } 72 | 73 | PAGE_1 = { 74 | "id": PAGE_1_ID, 75 | "partOf": PENDING_FOLLOWERS_ID, 76 | "prev": PAGE_2_ID, 77 | "orderedItems": [FOLLOW_2], 78 | } 79 | 80 | TOKEN_FILE_DATA = json.dumps( 81 | {"actor_id": "https://social.example/users/evanp", "access_token": "12345678"} 82 | ) 83 | 84 | 85 | def mock_oauth_get(url, headers=None): 86 | if url == ACTOR_ID: 87 | return MagicMock(status_code=200, json=lambda: ACTOR) 88 | elif url == PENDING_FOLLOWERS_ID: 89 | return MagicMock(status_code=200, json=lambda: PENDING_FOLLOWERS) 90 | elif url == PAGE_2_ID: 91 | return MagicMock(status_code=200, json=lambda: PAGE_2) 92 | elif url == PAGE_1_ID: 93 | return MagicMock(status_code=200, json=lambda: PAGE_1) 94 | elif url == OTHER_2_ID: 95 | return MagicMock(status_code=200, json=lambda: OTHER_2) 96 | elif url == FOLLOW_2_ID: 97 | return MagicMock(status_code=200, json=lambda: FOLLOW_2) 98 | else: 99 | return MagicMock(status_code=404) 100 | 101 | 102 | def mock_oauth_post(url, headers=None, data=None): 103 | if url == ACTOR["endpoints"]["proxyUrl"]: 104 | if data["id"] == OTHER_1_ID: 105 | return MagicMock(status_code=200, json=lambda: OTHER_1) 106 | if data["id"] == FOLLOW_1_ID: 107 | return MagicMock(status_code=200, json=lambda: FOLLOW_1) 108 | else: 109 | return MagicMock(status_code=404) 110 | elif url == ACTOR["outbox"]: 111 | added_data = { 112 | "id": REJECT_ID, 113 | "published": "2020-01-01T00:00:00Z", 114 | "actor": ACTOR_ID, 115 | } 116 | return MagicMock( 117 | status_code=200, json=lambda: {**json.loads(data), **added_data} 118 | ) 119 | else: 120 | return MagicMock(status_code=404) 121 | 122 | 123 | class TestRejectFollowerCommand(unittest.TestCase): 124 | def setUp(self): 125 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 126 | 127 | def tearDown(self): 128 | sys.stdout = self.held 129 | 130 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 131 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 132 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 133 | def test_reject_follower_remote( 134 | self, mock_requests_get, mock_requests_post, mock_file 135 | ): 136 | run_command(["reject", "follower", OTHER_1_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 137 | 138 | # Assertions 139 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 140 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 141 | self.assertIn(OTHER_1_ID, sys.stdout.getvalue()) 142 | 143 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 144 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 145 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 146 | def test_reject_follower_local( 147 | self, mock_requests_get, mock_requests_post, mock_file 148 | ): 149 | run_command(["reject", "follower", OTHER_2_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 150 | 151 | # Assertions 152 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 153 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 154 | self.assertIn(OTHER_2_ID, sys.stdout.getvalue()) 155 | 156 | 157 | if __name__ == "__main__": 158 | unittest.main() 159 | -------------------------------------------------------------------------------- /tests/test_remove.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | COLLECTION_ID = f"{ACTOR_ID}/collection/1" 13 | OBJECT_ID = f"{ACTOR_ID}/object/1" 14 | REMOVE_ID = f"{ACTOR_ID}/remove/1" 15 | 16 | ACTOR = { 17 | "type": "Person", 18 | "id": ACTOR_ID, 19 | "outbox": f"{ACTOR_ID}/outbox", 20 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 21 | } 22 | 23 | COLLECTION = { 24 | "id": COLLECTION_ID, 25 | "attributedTo": ACTOR_ID, 26 | "type": "Collection", 27 | "name": "A generic collection", 28 | "totalItems": 1, 29 | "items": [OBJECT_ID], 30 | } 31 | 32 | OBJECT = { 33 | "id": OBJECT_ID, 34 | "attributedTo": ACTOR_ID, 35 | "type": "Object", 36 | "name": "A generic object", 37 | } 38 | 39 | TOKEN_FILE_DATA = json.dumps( 40 | {"actor_id": "https://social.example/users/evanp", "access_token": "12345678"} 41 | ) 42 | 43 | def mock_oauth_get(url, headers=None): 44 | if url == ACTOR_ID: 45 | return MagicMock(status_code=200, json=lambda: ACTOR) 46 | elif url == COLLECTION_ID: 47 | return MagicMock(status_code=200, json=lambda: COLLECTION) 48 | elif url == OBJECT_ID: 49 | return MagicMock(status_code=200, json=lambda: OBJECT) 50 | else: 51 | return MagicMock(status_code=404) 52 | 53 | def mock_oauth_post(url, headers=None, data=None): 54 | if url == ACTOR["outbox"]: 55 | input_data = json.loads(data) 56 | added_data = { 57 | "id": REMOVE_ID, 58 | "published": "2020-01-01T00:00:00Z", 59 | "actor": ACTOR_ID 60 | } 61 | return MagicMock( 62 | status_code=200, json=lambda: {**input_data, **added_data} 63 | ) 64 | else: 65 | return MagicMock(status_code=404) 66 | 67 | class TestRemove(unittest.TestCase): 68 | 69 | def setUp(self): 70 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 71 | 72 | def tearDown(self): 73 | sys.stdout = self.held 74 | 75 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 76 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 77 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 78 | def test_remove( 79 | self, mock_requests_get, mock_requests_post, mock_file 80 | ): 81 | run_command(["remove", "--target", COLLECTION_ID, OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 82 | 83 | # Assertions 84 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 85 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 86 | self.assertIn(REMOVE_ID, sys.stdout.getvalue()) -------------------------------------------------------------------------------- /tests/test_replies.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | OBJECT_ID = f"{OTHER_ID}/objects/1" 14 | REPLIES_ID = f"{OBJECT_ID}/replies" 15 | REMOTE_ID = "https://remote.example/users/other" 16 | REMOTE_OBJECT_ID = f"{REMOTE_ID}/objects/1" 17 | REMOTE_REPLIES_ID = f"{REMOTE_OBJECT_ID}/replies" 18 | 19 | ANOTHER_ACTOR_ID = "https://social.example/users/another" 20 | AND_ANOTHER_ACTOR_ID = "https://social.example/users/andanother" 21 | 22 | REPLY_1_ID = f"{ANOTHER_ACTOR_ID}/objects/1" 23 | REPLY_2_ID = f"{AND_ANOTHER_ACTOR_ID}/objects/2" 24 | REPLY_3_ID = f"{ACTOR_ID}/objects/3" 25 | REPLY_4_ID = f"{OTHER_ID}/objects/4" 26 | REPLY_5_ID = f"{REMOTE_ID}/objects/5" 27 | 28 | ACTOR = { 29 | "type": "Person", 30 | "id": ACTOR_ID, 31 | "outbox": f"{ACTOR_ID}/outbox", 32 | "preferredUsername": "evanp", 33 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 34 | } 35 | 36 | OTHER = { 37 | "type": "Person", 38 | "id": OTHER_ID, 39 | "preferredUsername": "other", 40 | } 41 | 42 | OBJECT = { 43 | "type": "Note", 44 | "id": OBJECT_ID, 45 | "replies": REPLIES_ID, 46 | "attributedTo": OTHER_ID, 47 | "contentMap": { 48 | "en": "This is a note" 49 | } 50 | } 51 | 52 | ANOTHER_ACTOR = { 53 | "type": "Person", 54 | "id": ANOTHER_ACTOR_ID, 55 | "preferredUsername": "another", 56 | } 57 | 58 | AND_ANOTHER_ACTOR = { 59 | "type": "Person", 60 | "id": AND_ANOTHER_ACTOR_ID, 61 | "preferredUsername": "andanother", 62 | } 63 | 64 | REPLY_1 = { 65 | "type": "Note", 66 | "id": REPLY_1_ID, 67 | "inReplyTo": OBJECT_ID, 68 | "attributedTo": ANOTHER_ACTOR_ID, 69 | "contentMap": { 70 | "en": "You're doing it wrong." 71 | }, 72 | "published": "2020-01-01T00:00:00Z", 73 | } 74 | 75 | REPLY_2 = { 76 | "type": "Note", 77 | "id": REPLY_2_ID, 78 | "inReplyTo": REMOTE_OBJECT_ID, 79 | "attributedTo": AND_ANOTHER_ACTOR_ID, 80 | "contentMap": { 81 | "en": "You're doing it wrong." 82 | }, 83 | "published": "2020-01-01T00:00:00Z", 84 | } 85 | 86 | REPLY_3 = { 87 | "type": "Note", 88 | "id": REPLY_3_ID, 89 | "inReplyTo": OBJECT_ID, 90 | "attributedTo": ACTOR_ID, 91 | "contentMap": { 92 | "en": "You're doing it wrong." 93 | }, 94 | "published": "2020-01-01T00:00:00Z", 95 | } 96 | 97 | REPLY_4 = { 98 | "type": "Note", 99 | "id": REPLY_4_ID, 100 | "inReplyTo": REMOTE_OBJECT_ID, 101 | "attributedTo": OTHER_ID, 102 | "contentMap": { 103 | "en": "You're doing it wrong." 104 | }, 105 | "published": "2020-01-01T00:00:00Z", 106 | } 107 | 108 | REPLY_5 = { 109 | "type": "Note", 110 | "id": REPLY_5_ID, 111 | "inReplyTo": OBJECT_ID, 112 | "attributedTo": REMOTE_ID, 113 | "contentMap": { 114 | "en": "You're doing it wrong." 115 | }, 116 | "published": "2020-01-01T00:00:00Z", 117 | } 118 | 119 | REPLIES = { 120 | "id": REPLIES_ID, 121 | "attributedTo": OTHER_ID, 122 | "type": "Collection", 123 | "totalItems": 3, 124 | "items": [ 125 | REPLY_1_ID, 126 | REPLY_3_ID, 127 | REPLY_5_ID, 128 | ] 129 | } 130 | 131 | REMOTE = { 132 | "type": "Person", 133 | "id": REMOTE_ID, 134 | "preferredUsername": "other", 135 | } 136 | 137 | REMOTE_OBJECT = { 138 | "type": "Object", 139 | "id": REMOTE_OBJECT_ID, 140 | "attributedTo": REMOTE_ID, 141 | "replies": REMOTE_REPLIES_ID, 142 | } 143 | 144 | REMOTE_REPLIES = { 145 | "id": REMOTE_REPLIES_ID, 146 | "attributedTo": REMOTE_ID, 147 | "type": "Collection", 148 | "totalItems": 2, 149 | "items": [ 150 | REPLY_2_ID, 151 | REPLY_4_ID, 152 | ] 153 | } 154 | 155 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 156 | 157 | def mock_oauth_get(url, headers=None): 158 | if url == ACTOR_ID: 159 | return MagicMock(status_code=200, json=lambda: ACTOR) 160 | elif url == OTHER_ID: 161 | return MagicMock(status_code=200, json=lambda: OTHER) 162 | elif url == OBJECT_ID: 163 | return MagicMock(status_code=200, json=lambda: OBJECT) 164 | elif url == REPLIES_ID: 165 | return MagicMock(status_code=200, json=lambda: REPLIES) 166 | elif url == REPLY_1_ID: 167 | return MagicMock(status_code=200, json=lambda: REPLY_1) 168 | elif url == REPLY_2_ID: 169 | return MagicMock(status_code=200, json=lambda: REPLY_2) 170 | elif url == REPLY_3_ID: 171 | return MagicMock(status_code=200, json=lambda: REPLY_3) 172 | elif url == REPLY_4_ID: 173 | return MagicMock(status_code=200, json=lambda: REPLY_4) 174 | elif url == ANOTHER_ACTOR_ID: 175 | return MagicMock(status_code=200, json=lambda: ANOTHER_ACTOR) 176 | elif url == AND_ANOTHER_ACTOR_ID: 177 | return MagicMock(status_code=200, json=lambda: AND_ANOTHER_ACTOR) 178 | else: 179 | return MagicMock(status_code=404) 180 | 181 | 182 | def mock_oauth_post(url, headers=None, data=None): 183 | if url == ACTOR["endpoints"]["proxyUrl"]: 184 | if data["id"] == REMOTE_ID: 185 | return MagicMock(status_code=200, json=lambda: REMOTE) 186 | if data["id"] == REMOTE_OBJECT_ID: 187 | return MagicMock(status_code=200, json=lambda: REMOTE_OBJECT) 188 | if data["id"] == REMOTE_REPLIES_ID: 189 | return MagicMock(status_code=200, json=lambda: REMOTE_REPLIES) 190 | if data["id"] == REPLY_5_ID: 191 | return MagicMock(status_code=200, json=lambda: REPLY_5) 192 | else: 193 | return MagicMock(status_code=404) 194 | else: 195 | return MagicMock(status_code=404) 196 | 197 | 198 | class TestRepliesCommand(unittest.TestCase): 199 | def setUp(self): 200 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 201 | 202 | def tearDown(self): 203 | sys.stdout = self.held 204 | 205 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 206 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 207 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 208 | def test_replies_local(self, mock_requests_post, mock_requests_get, mock_file): 209 | run_command(["replies", OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 210 | 211 | # Assertions 212 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 213 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 214 | output = sys.stdout.getvalue() 215 | self.assertIn(REPLY_1_ID, output) 216 | self.assertIn(REPLY_3_ID, output) 217 | self.assertIn(REPLY_5_ID, output) 218 | self.assertNotIn(REPLY_2_ID, output) 219 | self.assertNotIn(REPLY_4_ID, output) 220 | self.assertIn("another@social.example", output) 221 | self.assertIn(REPLY_3["contentMap"]["en"], output) 222 | 223 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 224 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 225 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 226 | def test_replies_remote(self, mock_requests_post, mock_requests_get, mock_file): 227 | run_command(["replies", REMOTE_OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 228 | 229 | # Assertions 230 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 231 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 232 | output = sys.stdout.getvalue() 233 | self.assertNotIn(REPLY_1_ID, output) 234 | self.assertNotIn(REPLY_3_ID, output) 235 | self.assertNotIn(REPLY_5_ID, output) 236 | self.assertIn(REPLY_2_ID, output) 237 | self.assertIn(REPLY_4_ID, output) 238 | self.assertIn("andanother@social.example", output) 239 | self.assertIn(REPLY_4["contentMap"]["en"], output) 240 | 241 | 242 | if __name__ == "__main__": 243 | unittest.main() 244 | -------------------------------------------------------------------------------- /tests/test_share.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | OBJECT_ID = f"{OTHER_ID}/objects/1" 14 | SHARES_ID = f"{OBJECT_ID}/shares" 15 | REMOTE_ID = "https://remote.example/users/other" 16 | REMOTE_OBJECT_ID = f"{REMOTE_ID}/objects/1" 17 | REMOTE_SHARES_ID = f"{REMOTE_OBJECT_ID}/shares" 18 | SHARE_ID = f"{ACTOR_ID}/shares/1" 19 | 20 | ACTOR = { 21 | "type": "Person", 22 | "id": ACTOR_ID, 23 | "outbox": f"{ACTOR_ID}/outbox", 24 | "preferredUsername": "evanp", 25 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 26 | } 27 | 28 | OTHER = { 29 | "type": "Person", 30 | "id": OTHER_ID, 31 | "preferredUsername": "other", 32 | } 33 | 34 | OBJECT = { 35 | "type": "Note", 36 | "id": OBJECT_ID, 37 | "shares": SHARES_ID, 38 | "attributedTo": OTHER_ID, 39 | "content": "This is a note", 40 | } 41 | 42 | SHARES = { 43 | "id": SHARES_ID, 44 | "attributedTo": OTHER_ID, 45 | "type": "Collection", 46 | "totalItems": 0, 47 | "items": [ 48 | ] 49 | } 50 | 51 | REMOTE = { 52 | "type": "Person", 53 | "id": REMOTE_ID, 54 | "preferredUsername": "other", 55 | } 56 | 57 | REMOTE_OBJECT = { 58 | "type": "Object", 59 | "id": REMOTE_OBJECT_ID, 60 | "attributedTo": REMOTE_ID, 61 | "shares": REMOTE_SHARES_ID, 62 | } 63 | 64 | REMOTE_SHARES = { 65 | "id": REMOTE_SHARES_ID, 66 | "attributedTo": REMOTE_ID, 67 | "type": "Collection", 68 | "totalItems": 0, 69 | "items": [ 70 | ] 71 | } 72 | 73 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 74 | 75 | def mock_oauth_get(url, headers=None): 76 | if url == ACTOR_ID: 77 | return MagicMock(status_code=200, json=lambda: ACTOR) 78 | elif url == OTHER_ID: 79 | return MagicMock(status_code=200, json=lambda: OTHER) 80 | elif url == OBJECT_ID: 81 | return MagicMock(status_code=200, json=lambda: OBJECT) 82 | elif url == SHARES_ID: 83 | return MagicMock(status_code=200, json=lambda: SHARES) 84 | else: 85 | return MagicMock(status_code=404) 86 | 87 | 88 | def mock_oauth_post(url, headers=None, data=None): 89 | if url == ACTOR["endpoints"]["proxyUrl"]: 90 | if data["id"] == REMOTE_ID: 91 | return MagicMock(status_code=200, json=lambda: REMOTE) 92 | if data["id"] == REMOTE_OBJECT_ID: 93 | return MagicMock(status_code=200, json=lambda: REMOTE_OBJECT) 94 | if data["id"] == REMOTE_SHARES_ID: 95 | return MagicMock(status_code=200, json=lambda: REMOTE_SHARES) 96 | else: 97 | return MagicMock(status_code=404) 98 | elif url == ACTOR["outbox"]: 99 | data = json.loads(data) 100 | added_data = { 101 | "id": SHARE_ID, 102 | "actor": ACTOR_ID, 103 | "published": "2020-01-01T00:00:00Z", 104 | } 105 | result = {**data, **added_data} 106 | return MagicMock(status_code=200, json=lambda: result) 107 | else: 108 | return MagicMock(status_code=404) 109 | 110 | 111 | class TestSharesCommand(unittest.TestCase): 112 | def setUp(self): 113 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 114 | 115 | def tearDown(self): 116 | sys.stdout = self.held 117 | 118 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 119 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 120 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 121 | def test_share_local(self, mock_requests_post, mock_requests_get, mock_file): 122 | run_command(["share", OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 123 | 124 | # Assertions 125 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 126 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 127 | output = sys.stdout.getvalue() 128 | self.assertIn(SHARE_ID, output) 129 | 130 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 131 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 132 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 133 | def test_shares_remote(self, mock_requests_post, mock_requests_get, mock_file): 134 | run_command(["share", REMOTE_OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 135 | 136 | # Assertions 137 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 138 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 139 | output = sys.stdout.getvalue() 140 | self.assertIn(SHARE_ID, output) 141 | 142 | 143 | if __name__ == "__main__": 144 | unittest.main() 145 | -------------------------------------------------------------------------------- /tests/test_shares.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | OBJECT_ID = f"{OTHER_ID}/objects/1" 14 | SHARES_ID = f"{OBJECT_ID}/shares" 15 | REMOTE_ID = "https://remote.example/users/other" 16 | REMOTE_OBJECT_ID = f"{REMOTE_ID}/objects/1" 17 | REMOTE_SHARES_ID = f"{REMOTE_OBJECT_ID}/shares" 18 | 19 | ANOTHER_ACTOR_ID = "https://social.example/users/another" 20 | AND_ANOTHER_ACTOR_ID = "https://social.example/users/andanother" 21 | 22 | SHARE_1_ID = f"{ANOTHER_ACTOR_ID}/shares/1" 23 | SHARE_2_ID = f"{AND_ANOTHER_ACTOR_ID}/shares/2" 24 | SHARE_3_ID = f"{ACTOR_ID}/shares/3" 25 | SHARE_4_ID = f"{OTHER_ID}/shares/4" 26 | SHARE_5_ID = f"{REMOTE_ID}/shares/5" 27 | 28 | ACTOR = { 29 | "type": "Person", 30 | "id": ACTOR_ID, 31 | "outbox": f"{ACTOR_ID}/outbox", 32 | "preferredUsername": "evanp", 33 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 34 | } 35 | 36 | OTHER = { 37 | "type": "Person", 38 | "id": OTHER_ID, 39 | "preferredUsername": "other", 40 | } 41 | 42 | OBJECT = { 43 | "type": "Note", 44 | "id": OBJECT_ID, 45 | "shares": SHARES_ID, 46 | "attributedTo": OTHER_ID, 47 | "content": "This is a note", 48 | } 49 | 50 | ANOTHER_ACTOR = { 51 | "type": "Person", 52 | "id": ANOTHER_ACTOR_ID, 53 | "preferredUsername": "another", 54 | } 55 | 56 | AND_ANOTHER_ACTOR = { 57 | "type": "Person", 58 | "id": AND_ANOTHER_ACTOR_ID, 59 | "preferredUsername": "andanother", 60 | } 61 | 62 | SHARE_1 = { 63 | "type": "Announce", 64 | "id": SHARE_1_ID, 65 | "object": OBJECT_ID, 66 | "actor": ANOTHER_ACTOR_ID, 67 | "published": "2020-01-01T00:00:00Z", 68 | } 69 | 70 | SHARE_2 = { 71 | "type": "Announce", 72 | "id": SHARE_2_ID, 73 | "object": REMOTE_OBJECT_ID, 74 | "actor": AND_ANOTHER_ACTOR_ID, 75 | "published": "2020-01-01T00:00:00Z", 76 | } 77 | 78 | SHARE_3 = { 79 | "type": "Announce", 80 | "id": SHARE_3_ID, 81 | "object": OBJECT_ID, 82 | "actor": ACTOR_ID, 83 | "published": "2020-01-01T00:00:00Z", 84 | } 85 | 86 | SHARE_4 = { 87 | "type": "Announce", 88 | "id": SHARE_4_ID, 89 | "object": REMOTE_OBJECT_ID, 90 | "actor": OTHER_ID, 91 | "published": "2020-01-01T00:00:00Z", 92 | } 93 | 94 | SHARE_5 = { 95 | "type": "Announce", 96 | "id": SHARE_5_ID, 97 | "object": OBJECT_ID, 98 | "actor": REMOTE_ID, 99 | "published": "2020-01-01T00:00:00Z", 100 | } 101 | 102 | SHARES = { 103 | "id": SHARES_ID, 104 | "attributedTo": OTHER_ID, 105 | "type": "Collection", 106 | "totalItems": 3, 107 | "items": [ 108 | SHARE_1_ID, 109 | SHARE_3_ID, 110 | SHARE_5_ID, 111 | ] 112 | } 113 | 114 | REMOTE = { 115 | "type": "Person", 116 | "id": REMOTE_ID, 117 | "preferredUsername": "other", 118 | } 119 | 120 | REMOTE_OBJECT = { 121 | "type": "Object", 122 | "id": REMOTE_OBJECT_ID, 123 | "attributedTo": REMOTE_ID, 124 | "shares": REMOTE_SHARES_ID, 125 | } 126 | 127 | REMOTE_SHARES = { 128 | "id": REMOTE_SHARES_ID, 129 | "attributedTo": REMOTE_ID, 130 | "type": "Collection", 131 | "totalItems": 2, 132 | "items": [ 133 | SHARE_2_ID, 134 | SHARE_4_ID, 135 | ] 136 | } 137 | 138 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 139 | 140 | def mock_oauth_get(url, headers=None): 141 | if url == ACTOR_ID: 142 | return MagicMock(status_code=200, json=lambda: ACTOR) 143 | elif url == OTHER_ID: 144 | return MagicMock(status_code=200, json=lambda: OTHER) 145 | elif url == OBJECT_ID: 146 | return MagicMock(status_code=200, json=lambda: OBJECT) 147 | elif url == SHARES_ID: 148 | return MagicMock(status_code=200, json=lambda: SHARES) 149 | elif url == SHARE_1_ID: 150 | return MagicMock(status_code=200, json=lambda: SHARE_1) 151 | elif url == SHARE_2_ID: 152 | return MagicMock(status_code=200, json=lambda: SHARE_2) 153 | elif url == SHARE_3_ID: 154 | return MagicMock(status_code=200, json=lambda: SHARE_3) 155 | elif url == SHARE_4_ID: 156 | return MagicMock(status_code=200, json=lambda: SHARE_4) 157 | elif url == ANOTHER_ACTOR_ID: 158 | return MagicMock(status_code=200, json=lambda: ANOTHER_ACTOR) 159 | elif url == AND_ANOTHER_ACTOR_ID: 160 | return MagicMock(status_code=200, json=lambda: AND_ANOTHER_ACTOR) 161 | else: 162 | return MagicMock(status_code=404) 163 | 164 | 165 | def mock_oauth_post(url, headers=None, data=None): 166 | if url == ACTOR["endpoints"]["proxyUrl"]: 167 | if data["id"] == REMOTE_ID: 168 | return MagicMock(status_code=200, json=lambda: REMOTE) 169 | if data["id"] == REMOTE_OBJECT_ID: 170 | return MagicMock(status_code=200, json=lambda: REMOTE_OBJECT) 171 | if data["id"] == REMOTE_SHARES_ID: 172 | return MagicMock(status_code=200, json=lambda: REMOTE_SHARES) 173 | if data["id"] == SHARE_5_ID: 174 | return MagicMock(status_code=200, json=lambda: SHARE_5) 175 | else: 176 | return MagicMock(status_code=404) 177 | else: 178 | return MagicMock(status_code=404) 179 | 180 | 181 | class TestLikesCommand(unittest.TestCase): 182 | def setUp(self): 183 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 184 | 185 | def tearDown(self): 186 | sys.stdout = self.held 187 | 188 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 189 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 190 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 191 | def test_shares_local(self, mock_requests_post, mock_requests_get, mock_file): 192 | run_command(["shares", OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 193 | 194 | # Assertions 195 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 196 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 197 | output = sys.stdout.getvalue() 198 | self.assertIn(SHARE_1_ID, output) 199 | self.assertIn(SHARE_3_ID, output) 200 | self.assertIn(SHARE_5_ID, output) 201 | self.assertNotIn(SHARE_2_ID, output) 202 | self.assertNotIn(SHARE_4_ID, output) 203 | 204 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 205 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 206 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 207 | def test_shares_remote(self, mock_requests_post, mock_requests_get, mock_file): 208 | run_command(["shares", REMOTE_OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 209 | 210 | # Assertions 211 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 212 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 213 | output = sys.stdout.getvalue() 214 | self.assertNotIn(SHARE_1_ID, output) 215 | self.assertNotIn(SHARE_3_ID, output) 216 | self.assertNotIn(SHARE_5_ID, output) 217 | self.assertIn(SHARE_2_ID, output) 218 | self.assertIn(SHARE_4_ID, output) 219 | 220 | 221 | if __name__ == "__main__": 222 | unittest.main() 223 | -------------------------------------------------------------------------------- /tests/test_undo_follow.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_1_ID = "https://different.example/users/other1" 13 | OTHER_2_ID = "https://social.example/users/other2" 14 | OTHER_3_ID = "https://different.example/users/other3" 15 | OTHER_4_ID = "https://social.example/users/other4" 16 | PENDING_FOLLOWING_ID = f"{ACTOR_ID}/following/pending" 17 | FOLLOWING_ID = f"{ACTOR_ID}/following" 18 | PENDING_FOLLOWING_PAGE_ID = f"{PENDING_FOLLOWING_ID}/page/1" 19 | FOLLOWING_PAGE_ID = f"{FOLLOWING_ID}/page/1" 20 | FOLLOW_1_ID = f"{ACTOR_ID}/follows/1" 21 | FOLLOW_2_ID = f"{ACTOR_ID}/follows/2" 22 | FOLLOW_3_ID = f"{ACTOR_ID}/follows/3" 23 | FOLLOW_4_ID = f"{ACTOR_ID}/follows/4" 24 | OUTBOX_ID = f"{ACTOR_ID}/outbox" 25 | OUTBOX_PAGE_ID = f"{OUTBOX_ID}/page/1" 26 | UNDO_ID = f"{ACTOR_ID}/undo/1" 27 | 28 | ACTOR = { 29 | "type": "Person", 30 | "id": ACTOR_ID, 31 | "outbox": OUTBOX_ID, 32 | "inbox": f"{ACTOR_ID}/inbox", 33 | "pendingFollowing": PENDING_FOLLOWING_ID, 34 | "following": FOLLOWING_ID, 35 | "preferredUsername": "evanp", 36 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 37 | } 38 | 39 | OTHER_1 = { 40 | "type": "Person", 41 | "id": OTHER_1_ID, 42 | "outbox": f"{OTHER_1_ID}/outbox", 43 | "inbox": f"{OTHER_1_ID}/inbox", 44 | "preferredUsername": "other1", 45 | } 46 | 47 | OTHER_2 = { 48 | "type": "Person", 49 | "id": OTHER_2_ID, 50 | "outbox": f"{OTHER_2_ID}/outbox", 51 | "inbox": f"{OTHER_2_ID}/inbox", 52 | "preferredUsername": "other2", 53 | } 54 | 55 | OTHER_3 = { 56 | "type": "Person", 57 | "id": OTHER_3_ID, 58 | "outbox": f"{OTHER_1_ID}/outbox", 59 | "inbox": f"{OTHER_1_ID}/inbox", 60 | "preferredUsername": "other3", 61 | } 62 | 63 | OTHER_4 = { 64 | "type": "Person", 65 | "id": OTHER_4_ID, 66 | "outbox": f"{OTHER_2_ID}/outbox", 67 | "inbox": f"{OTHER_2_ID}/inbox", 68 | "preferredUsername": "other4", 69 | } 70 | 71 | FOLLOW_1 = { 72 | "id": FOLLOW_1_ID, 73 | "actor": ACTOR_ID, 74 | "type": "Follow", 75 | "object": OTHER_1_ID, 76 | "published": "2020-01-01T00:00:00Z", 77 | } 78 | 79 | FOLLOW_2 = { 80 | "id": FOLLOW_2_ID, 81 | "actor": ACTOR_ID, 82 | "type": "Follow", 83 | "object": OTHER_2_ID, 84 | "published": "2020-01-01T00:00:00Z", 85 | } 86 | 87 | FOLLOW_3 = { 88 | "id": FOLLOW_3_ID, 89 | "actor": ACTOR_ID, 90 | "type": "Follow", 91 | "object": OTHER_3_ID, 92 | "published": "2020-01-01T00:00:00Z", 93 | } 94 | 95 | FOLLOW_4 = { 96 | "id": FOLLOW_4_ID, 97 | "actor": ACTOR_ID, 98 | "type": "Follow", 99 | "object": OTHER_4_ID, 100 | "published": "2020-01-01T00:00:00Z", 101 | } 102 | 103 | PENDING_FOLLOWING = { 104 | "id": PENDING_FOLLOWING_ID, 105 | "attributedTo": ACTOR_ID, 106 | "first": PENDING_FOLLOWING_PAGE_ID, 107 | } 108 | 109 | PENDING_FOLLOWING_PAGE = { 110 | "id": PENDING_FOLLOWING_PAGE_ID, 111 | "attributedTo": ACTOR_ID, 112 | "partOf": PENDING_FOLLOWING_ID, 113 | "orderedItems": [FOLLOW_1, FOLLOW_2], 114 | } 115 | 116 | FOLLOWING = { 117 | "id": FOLLOWING_ID, 118 | "attributedTo": ACTOR_ID, 119 | "first": FOLLOWING_PAGE_ID, 120 | } 121 | 122 | FOLLOWING_PAGE = { 123 | "id": FOLLOWING_PAGE_ID, 124 | "attributedTo": ACTOR_ID, 125 | "partOf": FOLLOWING_ID, 126 | "orderedItems": [OTHER_3, OTHER_4], 127 | } 128 | 129 | OUTBOX = { 130 | "id": OUTBOX_ID, 131 | "attributedTo": ACTOR_ID, 132 | "first": OUTBOX_PAGE_ID 133 | } 134 | 135 | OUTBOX_PAGE = { 136 | "id": OUTBOX_PAGE_ID, 137 | "attributedTo": ACTOR_ID, 138 | "partOf": OUTBOX_ID, 139 | "orderedItems": [FOLLOW_3, FOLLOW_4], 140 | } 141 | 142 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 143 | 144 | 145 | def mock_oauth_get(url, headers=None): 146 | if url == ACTOR_ID: 147 | return MagicMock(status_code=200, json=lambda: ACTOR) 148 | elif url == PENDING_FOLLOWING_ID: 149 | return MagicMock(status_code=200, json=lambda: PENDING_FOLLOWING) 150 | elif url == FOLLOWING_ID: 151 | return MagicMock(status_code=200, json=lambda: FOLLOWING) 152 | elif url == PENDING_FOLLOWING_PAGE_ID: 153 | return MagicMock(status_code=200, json=lambda: PENDING_FOLLOWING_PAGE) 154 | elif url == FOLLOWING_PAGE_ID: 155 | return MagicMock(status_code=200, json=lambda: FOLLOWING_PAGE) 156 | elif url == OUTBOX_ID: 157 | return MagicMock(status_code=200, json=lambda: OUTBOX) 158 | elif url == OUTBOX_PAGE_ID: 159 | return MagicMock(status_code=200, json=lambda: OUTBOX_PAGE) 160 | elif url == OTHER_2_ID: 161 | return MagicMock(status_code=200, json=lambda: OTHER_2) 162 | elif url == OTHER_4_ID: 163 | return MagicMock(status_code=200, json=lambda: OTHER_4) 164 | elif url == FOLLOW_1_ID: 165 | return MagicMock(status_code=200, json=lambda: FOLLOW_1) 166 | elif url == FOLLOW_2_ID: 167 | return MagicMock(status_code=200, json=lambda: FOLLOW_2) 168 | elif url == FOLLOW_3_ID: 169 | return MagicMock(status_code=200, json=lambda: FOLLOW_3) 170 | elif url == FOLLOW_4_ID: 171 | return MagicMock(status_code=200, json=lambda: FOLLOW_4) 172 | else: 173 | return MagicMock(status_code=404) 174 | 175 | 176 | def mock_oauth_post(url, headers=None, data=None): 177 | if url == ACTOR["endpoints"]["proxyUrl"]: 178 | if data["id"] == OTHER_1_ID: 179 | return MagicMock(status_code=200, json=lambda: OTHER_1) 180 | if data["id"] == OTHER_3_ID: 181 | return MagicMock(status_code=200, json=lambda: OTHER_3) 182 | else: 183 | return MagicMock(status_code=404) 184 | elif url == OUTBOX_ID: 185 | new_data = { 186 | **json.loads(data), 187 | "id": UNDO_ID, 188 | "published": "2020-01-01T00:00:00Z", 189 | "actor": ACTOR_ID} 190 | return MagicMock(status_code=201, json=lambda: new_data) 191 | else: 192 | return MagicMock(status_code=404) 193 | 194 | 195 | class TestUndoFollowCommand(unittest.TestCase): 196 | def setUp(self): 197 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 198 | 199 | def tearDown(self): 200 | sys.stdout = self.held 201 | 202 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 203 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 204 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 205 | def test_undo_follow_pending_remote(self, mock_requests_post, mock_requests_get, mock_file): 206 | run_command(["undo", "follow", OTHER_1_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 207 | 208 | # Assertions 209 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 210 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 211 | self.assertIn(OTHER_1_ID, sys.stdout.getvalue()) 212 | 213 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 214 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 215 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 216 | def test_undo_follow_pending_local(self, mock_requests_post, mock_requests_get, mock_file): 217 | run_command(["undo", "follow", OTHER_2_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 218 | 219 | # Assertions 220 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 221 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 222 | self.assertIn(OTHER_2_ID, sys.stdout.getvalue()) 223 | 224 | 225 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 226 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 227 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 228 | def test_undo_follow_remote(self, mock_requests_post, mock_requests_get, mock_file): 229 | run_command(["undo", "follow", OTHER_3_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 230 | 231 | # Assertions 232 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 233 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 234 | self.assertIn(OTHER_3_ID, sys.stdout.getvalue()) 235 | 236 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 237 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 238 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 239 | def test_undo_follow_local(self, mock_requests_post, mock_requests_get, mock_file): 240 | run_command(["undo", "follow", OTHER_4_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 241 | 242 | 243 | # Assertions 244 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 245 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 246 | self.assertIn(OTHER_4_ID, sys.stdout.getvalue()) 247 | 248 | if __name__ == "__main__": 249 | unittest.main() 250 | -------------------------------------------------------------------------------- /tests/test_undo_like.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | OBJECT_ID = f"{OTHER_ID}/objects/1" 14 | LIKES_ID = f"{OBJECT_ID}/likes" 15 | REMOTE_ID = "https://remote.example/users/other" 16 | REMOTE_OBJECT_ID = f"{REMOTE_ID}/objects/1" 17 | REMOTE_LIKES_ID = f"{REMOTE_OBJECT_ID}/likes" 18 | LIKE_ID = f"{ACTOR_ID}/likes/1" 19 | LIKE_OF_REMOTE_ID = f"{ACTOR_ID}/likes/2" 20 | UNDO_LIKE_ID = f"{ACTOR_ID}/undo/1" 21 | 22 | NOT_LIKED_ID = f'{OTHER_ID}/objects/2' 23 | 24 | ACTOR = { 25 | "type": "Person", 26 | "id": ACTOR_ID, 27 | "outbox": f"{ACTOR_ID}/outbox", 28 | "liked": f"{ACTOR_ID}/liked", 29 | "preferredUsername": "evanp", 30 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 31 | } 32 | 33 | OTHER = { 34 | "type": "Person", 35 | "id": OTHER_ID, 36 | "preferredUsername": "other", 37 | } 38 | 39 | OBJECT = { 40 | "type": "Note", 41 | "id": OBJECT_ID, 42 | "likes": LIKES_ID, 43 | "attributedTo": OTHER_ID, 44 | "content": "This is a note", 45 | } 46 | 47 | LIKES = { 48 | "id": LIKES_ID, 49 | "attributedTo": OTHER_ID, 50 | "type": "Collection", 51 | "totalItems": 1, 52 | "items": [ 53 | LIKE_ID, 54 | ] 55 | } 56 | 57 | REMOTE = { 58 | "type": "Person", 59 | "id": REMOTE_ID, 60 | "preferredUsername": "other", 61 | } 62 | 63 | REMOTE_OBJECT = { 64 | "type": "Object", 65 | "id": REMOTE_OBJECT_ID, 66 | "attributedTo": REMOTE_ID, 67 | "likes": REMOTE_LIKES_ID, 68 | } 69 | 70 | REMOTE_LIKES = { 71 | "id": REMOTE_LIKES_ID, 72 | "attributedTo": REMOTE_ID, 73 | "type": "Collection", 74 | "totalItems": 1, 75 | "items": [ 76 | LIKE_OF_REMOTE_ID, 77 | ] 78 | } 79 | 80 | LIKE = { 81 | "id": LIKE_ID, 82 | "actor": ACTOR_ID, 83 | "type": "Like", 84 | "object": OBJECT_ID, 85 | "published": "2020-01-01T00:00:00Z", 86 | } 87 | 88 | LIKE_OF_REMOTE = { 89 | "id": LIKE_OF_REMOTE_ID, 90 | "actor": ACTOR_ID, 91 | "type": "Like", 92 | "object": REMOTE_OBJECT_ID, 93 | "published": "2020-01-01T00:00:00Z", 94 | } 95 | 96 | LIKED = { 97 | "id": ACTOR["liked"], 98 | "attributedTo": ACTOR_ID, 99 | "type": "Collection", 100 | "totalItems": 2, 101 | "items": [ 102 | OBJECT_ID, 103 | REMOTE_OBJECT_ID, 104 | ] 105 | } 106 | 107 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 108 | 109 | def mock_oauth_get(url, headers=None): 110 | if url == ACTOR_ID: 111 | return MagicMock(status_code=200, json=lambda: ACTOR) 112 | elif url == OTHER_ID: 113 | return MagicMock(status_code=200, json=lambda: OTHER) 114 | elif url == OBJECT_ID: 115 | return MagicMock(status_code=200, json=lambda: OBJECT) 116 | elif url == LIKES_ID: 117 | return MagicMock(status_code=200, json=lambda: LIKES) 118 | elif url == LIKE_ID: 119 | return MagicMock(status_code=200, json=lambda: LIKE) 120 | elif url == LIKE_OF_REMOTE_ID: 121 | return MagicMock(status_code=200, json=lambda: LIKE_OF_REMOTE) 122 | elif url == ACTOR["liked"]: 123 | return MagicMock(status_code=200, json=lambda: LIKED) 124 | else: 125 | return MagicMock(status_code=404) 126 | 127 | 128 | def mock_oauth_post(url, headers=None, data=None): 129 | if url == ACTOR["endpoints"]["proxyUrl"]: 130 | if data["id"] == REMOTE_ID: 131 | return MagicMock(status_code=200, json=lambda: REMOTE) 132 | if data["id"] == REMOTE_OBJECT_ID: 133 | return MagicMock(status_code=200, json=lambda: REMOTE_OBJECT) 134 | if data["id"] == REMOTE_LIKES_ID: 135 | return MagicMock(status_code=200, json=lambda: REMOTE_LIKES) 136 | else: 137 | return MagicMock(status_code=404) 138 | elif url == ACTOR["outbox"]: 139 | data = json.loads(data) 140 | added_data = { 141 | "id": UNDO_LIKE_ID, 142 | "actor": ACTOR_ID, 143 | "published": "2020-01-01T00:00:00Z", 144 | } 145 | result = {**data, **added_data} 146 | return MagicMock(status_code=200, json=lambda: result) 147 | else: 148 | return MagicMock(status_code=404) 149 | 150 | 151 | class TestUndoLikeCommand(unittest.TestCase): 152 | def setUp(self): 153 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 154 | 155 | def tearDown(self): 156 | sys.stdout = self.held 157 | 158 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 159 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 160 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 161 | def test_undo_like_local(self, mock_requests_post, mock_requests_get, mock_file): 162 | run_command(["undo", "like", OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 163 | 164 | # Assertions 165 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 166 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 167 | output = sys.stdout.getvalue() 168 | self.assertIn(UNDO_LIKE_ID, output) 169 | 170 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 171 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 172 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 173 | def test_undo_like_remote(self, mock_requests_post, mock_requests_get, mock_file): 174 | run_command(["undo", "like", REMOTE_OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 175 | 176 | # Assertions 177 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 178 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 179 | output = sys.stdout.getvalue() 180 | self.assertIn(UNDO_LIKE_ID, output) 181 | 182 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 183 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 184 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 185 | def test_undo_not_liked(self, mock_requests_post, mock_requests_get, mock_file): 186 | 187 | with self.assertRaises(Exception) as context: 188 | run_command(["undo", "like", NOT_LIKED_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 189 | 190 | if __name__ == "__main__": 191 | unittest.main() 192 | -------------------------------------------------------------------------------- /tests/test_undo_share.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | OTHER_ID = "https://social.example/users/other" 13 | OBJECT_ID = f"{OTHER_ID}/objects/1" 14 | SHARES_ID = f"{OBJECT_ID}/shares" 15 | REMOTE_ID = "https://remote.example/users/other" 16 | REMOTE_OBJECT_ID = f"{REMOTE_ID}/objects/1" 17 | REMOTE_SHARES_ID = f"{REMOTE_OBJECT_ID}/shares" 18 | SHARE_ID = f"{ACTOR_ID}/shares/1" 19 | SHARE_OF_REMOTE_ID = f"{ACTOR_ID}/shares/2" 20 | UNDO_SHARE_ID = f"{ACTOR_ID}/undo/1" 21 | 22 | NOT_SHARED_ID = f'{OTHER_ID}/objects/2' 23 | 24 | ACTOR = { 25 | "type": "Person", 26 | "id": ACTOR_ID, 27 | "outbox": f"{ACTOR_ID}/outbox", 28 | "shared": f"{ACTOR_ID}/shared", 29 | "preferredUsername": "evanp", 30 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 31 | } 32 | 33 | OTHER = { 34 | "type": "Person", 35 | "id": OTHER_ID, 36 | "preferredUsername": "other", 37 | } 38 | 39 | OBJECT = { 40 | "type": "Note", 41 | "id": OBJECT_ID, 42 | "shares": SHARES_ID, 43 | "attributedTo": OTHER_ID, 44 | "content": "This is a note", 45 | } 46 | 47 | SHARES = { 48 | "id": SHARES_ID, 49 | "attributedTo": OTHER_ID, 50 | "type": "Collection", 51 | "totalItems": 1, 52 | "items": [ 53 | SHARE_ID, 54 | ] 55 | } 56 | 57 | REMOTE = { 58 | "type": "Person", 59 | "id": REMOTE_ID, 60 | "preferredUsername": "other", 61 | } 62 | 63 | REMOTE_OBJECT = { 64 | "type": "Object", 65 | "id": REMOTE_OBJECT_ID, 66 | "attributedTo": REMOTE_ID, 67 | "shares": REMOTE_SHARES_ID, 68 | } 69 | 70 | REMOTE_SHARES = { 71 | "id": REMOTE_SHARES_ID, 72 | "attributedTo": REMOTE_ID, 73 | "type": "Collection", 74 | "totalItems": 1, 75 | "items": [ 76 | SHARE_OF_REMOTE_ID, 77 | ] 78 | } 79 | 80 | SHARE = { 81 | "id": SHARE_ID, 82 | "actor": ACTOR_ID, 83 | "type": "Announce", 84 | "object": OBJECT_ID, 85 | "published": "2020-01-01T00:00:00Z", 86 | } 87 | 88 | SHARE_OF_REMOTE = { 89 | "id": SHARE_OF_REMOTE_ID, 90 | "actor": ACTOR_ID, 91 | "type": "Announce", 92 | "object": REMOTE_OBJECT_ID, 93 | "published": "2020-01-01T00:00:00Z", 94 | } 95 | 96 | SHARED = { 97 | "id": ACTOR["shared"], 98 | "attributedTo": ACTOR_ID, 99 | "type": "Collection", 100 | "totalItems": 2, 101 | "items": [ 102 | OBJECT_ID, 103 | REMOTE_OBJECT_ID, 104 | ] 105 | } 106 | 107 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 108 | 109 | def mock_oauth_get(url, headers=None): 110 | if url == ACTOR_ID: 111 | return MagicMock(status_code=200, json=lambda: ACTOR) 112 | elif url == OTHER_ID: 113 | return MagicMock(status_code=200, json=lambda: OTHER) 114 | elif url == OBJECT_ID: 115 | return MagicMock(status_code=200, json=lambda: OBJECT) 116 | elif url == SHARES_ID: 117 | return MagicMock(status_code=200, json=lambda: SHARES) 118 | elif url == SHARE_ID: 119 | return MagicMock(status_code=200, json=lambda: SHARE) 120 | elif url == SHARE_OF_REMOTE_ID: 121 | return MagicMock(status_code=200, json=lambda: SHARE_OF_REMOTE) 122 | elif url == ACTOR["shared"]: 123 | return MagicMock(status_code=200, json=lambda: SHARED) 124 | else: 125 | return MagicMock(status_code=404) 126 | 127 | 128 | def mock_oauth_post(url, headers=None, data=None): 129 | if url == ACTOR["endpoints"]["proxyUrl"]: 130 | if data["id"] == REMOTE_ID: 131 | return MagicMock(status_code=200, json=lambda: REMOTE) 132 | if data["id"] == REMOTE_OBJECT_ID: 133 | return MagicMock(status_code=200, json=lambda: REMOTE_OBJECT) 134 | if data["id"] == REMOTE_SHARES_ID: 135 | return MagicMock(status_code=200, json=lambda: REMOTE_SHARES) 136 | else: 137 | return MagicMock(status_code=404) 138 | elif url == ACTOR["outbox"]: 139 | data = json.loads(data) 140 | added_data = { 141 | "id": UNDO_SHARE_ID, 142 | "actor": ACTOR_ID, 143 | "published": "2020-01-01T00:00:00Z", 144 | } 145 | result = {**data, **added_data} 146 | return MagicMock(status_code=200, json=lambda: result) 147 | else: 148 | return MagicMock(status_code=404) 149 | 150 | 151 | class TestUndoShareCommand(unittest.TestCase): 152 | def setUp(self): 153 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 154 | 155 | def tearDown(self): 156 | sys.stdout = self.held 157 | 158 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 159 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 160 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 161 | def test_undo_share_local(self, mock_requests_post, mock_requests_get, mock_file): 162 | run_command(["undo", "share", OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 163 | 164 | # Assertions 165 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 166 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 167 | output = sys.stdout.getvalue() 168 | self.assertIn(UNDO_SHARE_ID, output) 169 | 170 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 171 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 172 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 173 | def test_undo_share_remote(self, mock_requests_post, mock_requests_get, mock_file): 174 | run_command(["undo", "share", REMOTE_OBJECT_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 175 | 176 | # Assertions 177 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 178 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 179 | output = sys.stdout.getvalue() 180 | self.assertIn(UNDO_SHARE_ID, output) 181 | 182 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 183 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 184 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 185 | def test_undo_not_shared(self, mock_requests_post, mock_requests_get, mock_file): 186 | 187 | with self.assertRaises(Exception) as context: 188 | run_command(["undo", "share", NOT_SHARED_ID], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 189 | 190 | if __name__ == "__main__": 191 | unittest.main() 192 | -------------------------------------------------------------------------------- /tests/test_update_note.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | UPDATE_ID = f"{ACTOR_ID}/update/1" 13 | NOTE_ID = f"{ACTOR_ID}/notes/1" 14 | CONTENT = "Hello, world!" 15 | NEW_CONTENT = "Hello, world! (updated)" 16 | FOLLOWERS_ID = f"{ACTOR_ID}/followers" 17 | 18 | ACTOR = { 19 | "type": "Person", 20 | "id": ACTOR_ID, 21 | "outbox": f"{ACTOR_ID}/outbox", 22 | "followers": FOLLOWERS_ID, 23 | "endpoints": {"proxyUrl": "https://social.example/proxy"}, 24 | } 25 | 26 | ORIGINAL_NOTE = { 27 | "type": "Note", 28 | "id": NOTE_ID, 29 | "content": CONTENT, 30 | "attributedTo": ACTOR_ID, 31 | "published": "2020-01-01T00:00:00Z", 32 | "updated": "2020-01-01T00:00:00Z", 33 | "to": [FOLLOWERS_ID], 34 | } 35 | 36 | NEW_NOTE = { 37 | "type": "Note", 38 | "id": NOTE_ID, 39 | "content": NEW_CONTENT, 40 | "attributedTo": ACTOR_ID, 41 | "published": "2020-01-01T00:00:00Z", 42 | "updated": "2020-01-01T00:01:00Z", 43 | "to": [FOLLOWERS_ID], 44 | } 45 | 46 | TOKEN_FILE_DATA = json.dumps({ 47 | "actor_id": ACTOR_ID, 48 | "access_token": "12345678" 49 | }) 50 | 51 | updated = False 52 | 53 | def mock_oauth_get(url, headers=None): 54 | if url == ACTOR_ID: 55 | return MagicMock(status_code=200, json=lambda: ACTOR) 56 | elif url == NOTE_ID: 57 | if updated: 58 | return MagicMock(status_code=200, json=lambda: NEW_NOTE) 59 | else: 60 | return MagicMock(status_code=200, json=lambda: ORIGINAL_NOTE) 61 | else: 62 | return MagicMock(status_code=404) 63 | 64 | 65 | def mock_oauth_post(url, headers=None, data=None): 66 | if url == ACTOR["outbox"]: 67 | updated = True 68 | added_data = { 69 | "id": UPDATE_ID, 70 | "published": "2020-01-01T00:00:00Z", 71 | "actor": ACTOR_ID, 72 | "object": NEW_NOTE, 73 | } 74 | return MagicMock( 75 | status_code=200, json=lambda: {**json.loads(data), **added_data} 76 | ) 77 | else: 78 | return MagicMock(status_code=404) 79 | 80 | 81 | class TestUpdateNoteCommand(unittest.TestCase): 82 | def setUp(self): 83 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 84 | 85 | def tearDown(self): 86 | sys.stdout = self.held 87 | 88 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 89 | @patch("requests_oauthlib.OAuth2Session.post", side_effect=mock_oauth_post) 90 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 91 | def test_update_note(self, mock_requests_get, mock_requests_post, mock_file): 92 | run_command(["update", "note", NOTE_ID, NEW_CONTENT], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 93 | 94 | # Assertions 95 | self.assertGreaterEqual(mock_requests_get.call_count, 1) 96 | self.assertGreaterEqual(mock_requests_post.call_count, 1) 97 | self.assertIn(NEW_CONTENT, sys.stdout.getvalue()) 98 | self.assertIn(NEW_NOTE['updated'], sys.stdout.getvalue()) 99 | self.assertIn(UPDATE_ID, sys.stdout.getvalue()) -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import json 8 | 9 | ACTOR_ID = "evanp@social.example" 10 | 11 | SEMVER_REGEX = r'^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' 12 | 13 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 14 | 15 | class TestVersionCommand(unittest.TestCase): 16 | def setUp(self): 17 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 18 | 19 | def tearDown(self): 20 | sys.stdout = self.held 21 | 22 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 23 | def test_version(self, mock_file): 24 | run_command(["version"], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 25 | 26 | # Assertions 27 | output = sys.stdout.getvalue() 28 | self.assertRegex(output, r'^ap ') 29 | self.assertRegex(output[3:], SEMVER_REGEX) 30 | 31 | if __name__ == "__main__": 32 | unittest.main() 33 | -------------------------------------------------------------------------------- /tests/test_whoami.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open, MagicMock 3 | from ap.main import run_command 4 | from argparse import Namespace 5 | import io 6 | import sys 7 | import requests 8 | from requests_oauthlib import OAuth2Session 9 | import json 10 | 11 | ACTOR_ID = "https://social.example/users/evanp" 12 | ACTOR = { 13 | "type": "Person", 14 | "id": ACTOR_ID, 15 | "outbox": "https://social.example/users/evanp/outbox", 16 | "preferredUsername": "evanp", 17 | } 18 | TOKEN_FILE_DATA = json.dumps({"actor_id": ACTOR_ID, "access_token": "12345678"}) 19 | 20 | 21 | def mock_oauth_get(url, headers=None): 22 | if url == ACTOR_ID: 23 | return MagicMock(status_code=200, json=lambda: ACTOR) 24 | else: 25 | return MagicMock(status_code=404) 26 | 27 | 28 | class TestWhoamiCommand(unittest.TestCase): 29 | def setUp(self): 30 | self.held, sys.stdout = sys.stdout, io.StringIO() # Redirect stdout 31 | 32 | def tearDown(self): 33 | sys.stdout = self.held 34 | 35 | @patch("builtins.open", new_callable=mock_open, read_data=TOKEN_FILE_DATA) 36 | @patch("requests_oauthlib.OAuth2Session.get", side_effect=mock_oauth_get) 37 | def test_whoami(self, mock_requests_get, mock_file): 38 | run_command(["whoami"], {'LANG': 'en_CA.UTF-8', 'HOME': '/home/notauser'}) 39 | 40 | # Assertions 41 | mock_requests_get.assert_called_once() 42 | self.assertIn("evanp@social.example", sys.stdout.getvalue()) 43 | 44 | 45 | if __name__ == "__main__": 46 | unittest.main() 47 | --------------------------------------------------------------------------------