Browse Source

Jump to v1.14.0

pull/230/head
Tim 5 years ago
parent
commit
472c780346
No known key found for this signature in database GPG Key ID: B6C50F87ED7CD125
366 changed files with 10169 additions and 3617 deletions
  1. +1
    -1
      .travis.yml
  2. +111
    -11
      CHANGELOG.md
  3. +33
    -5
      README.md
  4. +0
    -2
      hc/accounts/admin.py
  5. +1
    -1
      hc/accounts/backends.py
  6. +42
    -0
      hc/accounts/management/commands/createsuperuser.py
  7. +13
    -6
      hc/accounts/management/commands/senddeletionnotices.py
  8. +1
    -5
      hc/accounts/middleware.py
  9. +23
    -0
      hc/accounts/migrations/0028_auto_20191119_1346.py
  10. +17
    -0
      hc/accounts/migrations/0029_remove_profile_current_project.py
  11. +40
    -16
      hc/accounts/models.py
  12. +1
    -10
      hc/accounts/tests/test_add_project.py
  13. +2
    -2
      hc/accounts/tests/test_check_token.py
  14. +2
    -8
      hc/accounts/tests/test_close_account.py
  15. +4
    -3
      hc/accounts/tests/test_login.py
  16. +0
    -1
      hc/accounts/tests/test_profile.py
  17. +26
    -12
      hc/accounts/tests/test_project.py
  18. +36
    -2
      hc/accounts/tests/test_project_model.py
  19. +4
    -2
      hc/accounts/tests/test_pruneusers.py
  20. +0
    -4
      hc/accounts/tests/test_remove_project.py
  21. +106
    -0
      hc/accounts/tests/test_senddeletionnotices.py
  22. +1
    -0
      hc/accounts/tests/test_signup.py
  23. +15
    -7
      hc/accounts/tests/test_unsubscribe_reports.py
  24. +1
    -1
      hc/accounts/urls.py
  25. +50
    -38
      hc/accounts/views.py
  26. +2
    -0
      hc/api/admin.py
  27. +25
    -6
      hc/api/management/commands/sendalerts.py
  28. +10
    -9
      hc/api/management/commands/sendreports.py
  29. +18
    -0
      hc/api/migrations/0064_auto_20191119_1346.py
  30. +23
    -0
      hc/api/migrations/0065_auto_20191127_1240.py
  31. +18
    -0
      hc/api/migrations/0066_channel_last_error.py
  32. +27
    -0
      hc/api/migrations/0067_last_error_values.py
  33. +18
    -0
      hc/api/migrations/0068_auto_20200117_1023.py
  34. +19
    -0
      hc/api/migrations/0069_auto_20200117_1227.py
  35. +109
    -42
      hc/api/models.py
  36. +1
    -0
      hc/api/schemas.py
  37. +4
    -0
      hc/api/tests/test_badge.py
  38. +16
    -0
      hc/api/tests/test_bounce.py
  39. +11
    -110
      hc/api/tests/test_channel_model.py
  40. +1
    -1
      hc/api/tests/test_check_model.py
  41. +9
    -1
      hc/api/tests/test_create_check.py
  42. +64
    -0
      hc/api/tests/test_get_check.py
  43. +262
    -63
      hc/api/tests/test_notify.py
  44. +46
    -23
      hc/api/tests/test_ping.py
  45. +2
    -1
      hc/api/tests/test_prunepingsslow.py
  46. +1
    -1
      hc/api/tests/test_sendalerts.py
  47. +29
    -13
      hc/api/tests/test_sendreports.py
  48. +72
    -9
      hc/api/tests/test_update_check.py
  49. +144
    -38
      hc/api/transports.py
  50. +3
    -15
      hc/api/urls.py
  51. +98
    -36
      hc/api/views.py
  52. +1
    -3
      hc/front/admin.py
  53. +18
    -0
      hc/front/decorators.py
  54. +78
    -5
      hc/front/forms.py
  55. +2
    -16
      hc/front/management/commands/pygmentize.py
  56. +39
    -0
      hc/front/management/commands/render_docs.py
  57. +1
    -3
      hc/front/models.py
  58. +24
    -0
      hc/front/templatetags/hc_extras.py
  59. +8
    -4
      hc/front/tests/test_add_apprise.py
  60. +0
    -13
      hc/front/tests/test_add_check.py
  61. +4
    -45
      hc/front/tests/test_add_discord.py
  62. +76
    -0
      hc/front/tests/test_add_discord_complete.py
  63. +19
    -10
      hc/front/tests/test_add_email.py
  64. +39
    -0
      hc/front/tests/test_add_matrix.py
  65. +7
    -5
      hc/front/tests/test_add_mattermost.py
  66. +25
    -0
      hc/front/tests/test_add_msteams.py
  67. +24
    -6
      hc/front/tests/test_add_opsgenie.py
  68. +4
    -2
      hc/front/tests/test_add_pagerteam.py
  69. +4
    -2
      hc/front/tests/test_add_pagertree.py
  70. +15
    -24
      hc/front/tests/test_add_pd.py
  71. +32
    -0
      hc/front/tests/test_add_pdc.py
  72. +26
    -0
      hc/front/tests/test_add_pdc_complete.py
  73. +21
    -0
      hc/front/tests/test_add_pdc_help.py
  74. +4
    -42
      hc/front/tests/test_add_pushbullet.py
  75. +71
    -0
      hc/front/tests/test_add_pushbullet_complete.py
  76. +20
    -22
      hc/front/tests/test_add_pushover.py
  77. +18
    -0
      hc/front/tests/test_add_pushover_help.py
  78. +55
    -0
      hc/front/tests/test_add_shell.py
  79. +8
    -9
      hc/front/tests/test_add_slack.py
  80. +15
    -71
      hc/front/tests/test_add_slack_btn.py
  81. +75
    -0
      hc/front/tests/test_add_slack_complete.py
  82. +14
    -0
      hc/front/tests/test_add_slack_help.py
  83. +5
    -3
      hc/front/tests/test_add_sms.py
  84. +22
    -3
      hc/front/tests/test_add_telegram.py
  85. +12
    -5
      hc/front/tests/test_add_trello.py
  86. +4
    -2
      hc/front/tests/test_add_victorops.py
  87. +40
    -7
      hc/front/tests/test_add_webhook.py
  88. +5
    -3
      hc/front/tests/test_add_whatsapp.py
  89. +80
    -0
      hc/front/tests/test_add_zulip.py
  90. +6
    -3
      hc/front/tests/test_channel_checks.py
  91. +26
    -16
      hc/front/tests/test_channels.py
  92. +18
    -0
      hc/front/tests/test_copy.py
  93. +1
    -1
      hc/front/tests/test_cron_preview.py
  94. +0
    -3
      hc/front/tests/test_details.py
  95. +84
    -0
      hc/front/tests/test_edit_webhook.py
  96. +31
    -0
      hc/front/tests/test_filtering_rules.py
  97. +9
    -5
      hc/front/tests/test_log.py
  98. +43
    -0
      hc/front/tests/test_metrics.py
  99. +16
    -4
      hc/front/tests/test_my_checks.py
  100. +5
    -3
      hc/front/tests/test_pause.py

+ 1
- 1
.travis.yml View File

@ -1,9 +1,9 @@
dist: xenial
language: python
python:
- "3.5"
- "3.6"
- "3.7"
- "3.8"
install:
- pip install -r requirements.txt
- pip install braintree coveralls mock mysqlclient reportlab apprise


+ 111
- 11
CHANGELOG.md View File

@ -1,20 +1,119 @@
# Changelog
All notable changes to this project will be documented in this file.
## Unreleased
## v1.15.0-dev - Unreleased
### Improvements
- Rate limiting for Telegram notifications (10 notifications per chat per minute)
- Use Slack V2 OAuth flow
- "Edit" function for webhook integrations (#176)
### Bug Fixes
- "Get a single check" API call now supports read-only API keys (#346)
- Don't escape HTML in the subject line of notification emails
## v1.14.0 - 2020-03-23
### Improvements
- Improved UI to invite users from account's other projects (#258)
- Experimental Prometheus metrics endpoint (#300)
- Don't store user's current project in DB, put it explicitly in page URLs (#336)
- API reference in Markdown
- Use Selectize.js for entering tags (#324)
- Zulip integration (#202)
- OpsGenie integration returns more detailed error messages
- Telegram integration returns more detailed error messages
- Added the "Get a single check" API call (#337)
- Display project name in Slack notifications (#342)
### Bug Fixes
- The "render_docs" command checks if markdown and pygments is installed (#329)
- The team size limit is applied to the n. of distinct users across all projects (#332)
- API: don't let SuspiciousOperation bubble up when validating channel ids
- API security: check channel ownership when setting check's channels
- API: update check's "alert_after" field when changing schedule
- API: validate channel identifiers before creating/updating a check (#335)
- Fix redirect after login when adding Telegram integration
## v1.13.0 - 2020-02-13
### Improvements
- Show a red "!" in project's top navigation if any integration is not working
- createsuperuser management command requires an unique email address (#318)
- For superusers, show "Site Administration" in top navigation, note in README (#317)
- Make Ping.body size limit configurable (#301)
- Show sub-second durations with higher precision, 2 digits after decimal point (#321)
- Replace the gear icon with three horizontal dots icon (#322)
- Add a Pause button in the checks list (#312)
- Documentation in Markdown
- Added an example of capturing and submitting log output (#315)
- The sendalerts commands measures dwell time and reports it over statsd protocol
- Django 3.0.3
- Show a warning in top navigation if the project has no integrations (#327)
### Bug Fixes
- Increase the allowable length of Matrix room alias to 100 (#320)
- Make sure Check.last_ping and Ping.created timestamps match exactly
- Don't trigger "down" notifications when changing schedule interactively in web UI
- Fix sendalerts crash loop when encountering a bad cron schedule
- Stricter cron validation, reject schedules like "At midnight of February 31"
- In hc.front.views.ping_details, if a ping does not exist, return a friendly message
## v1.12.0 - 2020-01-02
### Improvements
- Django 3.0
- "Filtering Rules" dialog, an option to require HTTP POST (#297)
- Show Healthchecks version in Django admin header (#306)
- Added JSON endpoint for Shields.io (#304)
- `senddeletionnotices` command skips profiles with recent last_active_date
- The "Update Check" API call can update check's description (#311)
### Bug Fixes
- Don't set CSRF cookie on first visit. Signup is exempt from CSRF protection
- Fix List-Unsubscribe email header value: add angle brackets
- Unsubscribe links serve a form, and require HTTP POST to actually unsubscribe
- For webhook integration, validate each header line separately
- Fix "Send Test Notification" for webhooks that only fire on checks going up
- Don't allow adding webhook integrations with both URLs blank
- Don't allow adding email integrations with both "up" and "down" unchecked
## v1.11.0 - 2019-11-22
### Improvements
- In monthly reports, no downtime stats for the current month (month has just started)
- Add Microsoft Teams integration (#135)
- Add Profile.last_active_date field for more accurate inactive user detection
- Add "Shell Commands" integration (#302)
- PagerDuty integration works with or without PD_VENDOR_KEY (#303)
### Bug Fixes
- On mobile, "My Checks" page, always show the gear (Details) button (#286)
- Make log events fit better on mobile screens
## v1.10.0 - 2019-10-21
### Improvements
- Add the "Last Duration" field in the "My Checks" page (#257)
- Add "last_duration" attribute to the Check API resource (#257)
- Upgrade to psycopg2 2.8.3
- Add Go usage example
- Send monthly reports on 1st of every month, not randomly during the month
- Signup form sets the "auto-login" cookie to avoid an extra click during first login
- Autofocus the email field in the signup form, and submit on enter key
- Add support for OpsGenie EU region (#294)
- Update OpsGenie logo and setup illustrations
- Add a "Create a Copy" function for cloning checks (#288)
- Send email notification when monthly SMS sending limit is reached (#292)
### Bug Fixes
- Usernames now are uuid3(const, email). Prevents multiple accts with same email (#290)
- Prevent double-clicking the submit button in signup form
- Upgrade to Django 2.2.6 – fixes sqlite migrations (#284)
## 1.9.0 - 2019-09-03
## v1.9.0 - 2019-09-03
### Improvements
- Show the number of downtimes and total downtime minutes in monthly reports (#104)
@ -28,7 +127,7 @@ All notable changes to this project will be documented in this file.
- Fix javascript code to construct correct URLs when running from a subdirectory (#273)
- Don't show the "Sign Up" link in the login page if registration is closed (#280)
## 1.8.0 - 2019-07-08
## v1.8.0 - 2019-07-08
### Improvements
- Add the `prunetokenbucket` management command
@ -47,7 +146,7 @@ All notable changes to this project will be documented in this file.
- Fix `prunepings` and `prunepingsslow`, they got broken when adding Projects (#264)
## 1.7.0 - 2019-05-02
## v1.7.0 - 2019-05-02
### Improvements
- Add the EMAIL_USE_VERIFICATION configuration setting (#232)
@ -60,7 +159,8 @@ All notable changes to this project will be documented in this file.
- Show the Description section even if the description is missing. (#246)
- Include the description in email alerts. (#247)
## 1.6.0 - 2019-04-01
## v1.6.0 - 2019-04-01
### Improvements
- Add the "desc" field (check's description) to API responses
@ -76,7 +176,7 @@ All notable changes to this project will be documented in this file.
- Fix a "invalid time format" in front.views.status_single on Windows hosts
## 1.5.0 - 2019-02-04
## v1.5.0 - 2019-02-04
### Improvements
- Database schema: add uniqueness constraint to Check.code
@ -88,7 +188,7 @@ All notable changes to this project will be documented in this file.
- Add the "My Projects" page
## 1.4.0 - 2018-12-25
## v1.4.0 - 2018-12-25
### Improvements
- Set Pushover alert priorities for "down" and "up" events separately
@ -106,7 +206,7 @@ All notable changes to this project will be documented in this file.
- Validate and reject cron schedules with six components
## 1.3.0 - 2018-11-21
## v1.3.0 - 2018-11-21
### Improvements
- Load settings from environment variables
@ -126,7 +226,7 @@ All notable changes to this project will be documented in this file.
- During DST transition, handle ambiguous dates as pre-transition
## 1.2.0 - 2018-10-20
## v1.2.0 - 2018-10-20
### Improvements
- Content updates in the "Welcome" page.
@ -141,7 +241,7 @@ All notable changes to this project will be documented in this file.
- Fix hamburger menu button in "Login" page.
## 1.1.0 - 2018-08-20
## v1.1.0 - 2018-08-20
### Improvements
- A new "Check Details" page.


+ 33
- 5
README.md View File

@ -20,8 +20,8 @@ It is live here: [http://healthchecks.io/](http://healthchecks.io/)
The building blocks are:
* Python 3
* Django 2
* Python 3.6+
* Django 3
* PostgreSQL or MySQL
## Setting Up for Development
@ -70,9 +70,9 @@ in development environment.
$ ./manage.py runserver
The site should now be running at `http://localhost:8080`
To log into Django administration site as a super user,
visit `http://localhost:8080/admin`
The site should now be running at `http://localhost:8000`.
To access Django administration site, log in as a super user, then
visit `http://localhost:8000/admin`
## Configuration
@ -112,6 +112,7 @@ Configurations settings loaded from environment variables:
| MASTER_BADGE_LABEL | `"Mychecks"`
| PING_ENDPOINT | `"http://localhost:8000/ping/"`
| PING_EMAIL_DOMAIN | `"localhost"`
| PING_BODY_LIMIT | 10000 | In bytes. Set to `None` to always log full request body
| DISCORD_CLIENT_ID | `None`
| DISCORD_CLIENT_SECRET | `None`
| SLACK_CLIENT_ID | `None`
@ -134,6 +135,7 @@ Configurations settings loaded from environment variables:
| MATRIX_USER_ID | `None`
| MATRIX_ACCESS_TOKEN | `None`
| APPRISE_ENABLED | `"False"`
| SHELL_ENABLED | `"False"`
Some useful settings keys to override are:
@ -167,6 +169,10 @@ Set it to `False` if you are setting up a private healthchecks instance where
you trust your users and want to avoid the extra verification step.
`PING_BODY_LIMIT` sets the size limit in bytes for logged ping request bodies.
The default value is 10000 (10 kilobytes). You can remove the limit altogether by
setting this value to `None`.
## Database Configuration
Database configuration is loaded from environment variables. If you
@ -188,6 +194,16 @@ DATABASES = {
}
```
## Accessing Administration Panel
healthchecks comes with Django's administation panel where you can manually
view and modify user accounts, projects, checks, integrations etc. To access it,
* if you haven't already, create a superuser account: `./manage.py createsuperuser`
* log into the site using superuser credentials
* in the top navigation, "Account" dropdown, select "Site Administration"
## Sending Emails
healthchecks must be able to send email messages, so it can send out login
@ -383,6 +399,17 @@ pip install apprise
```
* enable the apprise functionality by setting the `APPRISE_ENABLED` environment variable.
### Shell Commands
The "Shell Commands" integration runs user-defined local shell commands when checks
go up or down. This integration is disabled by default, and can be enabled by setting
the `SHELL_ENABLED` environment variable to `True`.
Note: be careful when using "Shell Commands" integration, and only enable it when
you fully trust the users of your Healthchecks instance. The commands will be executed
by the `manage.py sendalerts` process, and will run with the same system permissions as
the `sendalerts` process.
## Running in Production
Here is a non-exhaustive list of pointers and things to check before launching a Healthchecks instance
@ -405,6 +432,7 @@ in production.
Run the `manage.py collectstatic` command whenever files in the `/static/`
directory change. This command collects all the static files inside the `static-collected` directory.
Configure your web server to serve files from this directory under the `/static/` prefix.
* Database migration should be run after each update to make sure the database schemas are up to date. You can do that with `./manage.py migrate`.
* Processes that need to be running constantly.
* `manage.py runserver` is intended for development only. Do not use it in production,
instead consider using [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) or


+ 0
- 2
hc/accounts/admin.py View File

@ -22,7 +22,6 @@ class ProfileFieldset(Fieldset):
name = "User Profile"
fields = (
"email",
"current_project",
"reports_allowed",
"next_report_date",
"nag_period",
@ -51,7 +50,6 @@ class ProfileAdmin(admin.ModelAdmin):
css = {"all": ("css/admin/profiles.css",)}
readonly_fields = ("user", "email")
raw_id_fields = ("current_project",)
search_fields = ["id", "user__email"]
list_per_page = 50
list_select_related = ("user",)


+ 1
- 1
hc/accounts/backends.py View File

@ -5,7 +5,7 @@ from hc.accounts.models import Profile
class BasicBackend(object):
def get_user(self, user_id):
try:
q = User.objects.select_related("profile", "profile__current_project")
q = User.objects.select_related("profile")
return q.get(pk=user_id)
except User.DoesNotExist:


+ 42
- 0
hc/accounts/management/commands/createsuperuser.py View File

@ -0,0 +1,42 @@
import getpass
from django.core.management.base import BaseCommand
from hc.accounts.forms import AvailableEmailForm
from hc.accounts.views import _make_user
class Command(BaseCommand):
help = """Create a super-user account."""
def handle(self, *args, **options):
email = None
password = None
while not email:
raw = input("Email address:")
form = AvailableEmailForm({"identity": raw})
if not form.is_valid():
self.stderr.write("Error: " + " ".join(form.errors["identity"]))
continue
email = form.cleaned_data["identity"]
while not password:
p1 = getpass.getpass()
p2 = getpass.getpass("Password (again):")
if p1.strip() == "":
self.stderr.write("Error: Blank passwords aren't allowed.")
continue
if p1 != p2:
self.stderr.write("Error: Your passwords didn't match.")
continue
password = p1
user = _make_user(email)
user.set_password(password)
user.is_staff = True
user.is_superuser = True
user.save()
return "Superuser created successfully."

+ 13
- 6
hc/accounts/management/commands/senddeletionnotices.py View File

@ -19,31 +19,36 @@ class Command(BaseCommand):
"""
def pause(self):
time.sleep(1)
def handle(self, *args, **options):
year_ago = now() - timedelta(days=365)
q = Profile.objects.order_by("id")
# Exclude accounts with logins in the last year_ago
# Exclude accounts with logins in the last year
q = q.exclude(user__last_login__gt=year_ago)
# Exclude accounts less than a year_ago old
# Exclude accounts less than a year old
q = q.exclude(user__date_joined__gt=year_ago)
# Exclude accounts with the deletion notice already sent
q = q.exclude(deletion_notice_date__gt=year_ago)
# Exclude accounts with activity in the last year
q = q.exclude(last_active_date__gt=year_ago)
# Exclude paid accounts
q = q.exclude(sms_limit__gt=0)
q = q.exclude(sms_limit__gt=5)
sent = 0
for profile in q:
members = Member.objects.filter(project__owner_id=profile.user_id)
if members.exists():
print("Skipping %s, has team members" % profile)
self.stdout.write("Skipping %s, has team members" % profile)
continue
pings = Ping.objects
pings = pings.filter(owner__project__owner_id=profile.user_id)
pings = pings.filter(created__gt=year_ago)
if pings.exists():
print("Skipping %s, has pings in last year" % profile)
self.stdout.write("Skipping %s, has pings in last year" % profile)
continue
self.stdout.write("Sending notice to %s" % profile.user.email)
@ -53,8 +58,10 @@ class Command(BaseCommand):
ctx = {"email": profile.user.email, "support_email": settings.SUPPORT_EMAIL}
emails.deletion_notice(profile.user.email, ctx)
# Throttle so we don't send too many emails at once:
time.sleep(1)
self.pause()
sent += 1
return "Done! Sent %d notices" % sent

+ 1
- 5
hc/accounts/middleware.py View File

@ -9,9 +9,5 @@ class TeamAccessMiddleware(object):
if not request.user.is_authenticated:
return self.get_response(request)
profile = Profile.objects.for_user(request.user)
request.profile = profile
request.project = profile.current_project
request.profile = Profile.objects.for_user(request.user)
return self.get_response(request)

+ 23
- 0
hc/accounts/migrations/0028_auto_20191119_1346.py View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.6 on 2019-11-19 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0027_profile_deletion_notice_date'),
]
operations = [
migrations.AddField(
model_name='profile',
name='last_active_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='profile',
name='sms_limit',
field=models.IntegerField(default=5),
),
]

+ 17
- 0
hc/accounts/migrations/0029_remove_profile_current_project.py View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.1 on 2020-03-02 07:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0028_auto_20191119_1346'),
]
operations = [
migrations.RemoveField(
model_name='profile',
name='current_project',
),
]

+ 40
- 16
hc/accounts/models.py View File

@ -1,6 +1,5 @@
from base64 import urlsafe_b64encode
from datetime import timedelta
import os
from secrets import token_urlsafe
import uuid
from django.conf import settings
@ -53,13 +52,13 @@ class Profile(models.Model):
ping_log_limit = models.IntegerField(default=100)
check_limit = models.IntegerField(default=20)
token = models.CharField(max_length=128, blank=True)
current_project = models.ForeignKey("Project", models.SET_NULL, null=True)
last_sms_date = models.DateTimeField(null=True, blank=True)
sms_limit = models.IntegerField(default=0)
sms_limit = models.IntegerField(default=5)
sms_sent = models.IntegerField(default=0)
team_limit = models.IntegerField(default=2)
sort = models.CharField(max_length=20, default="created")
deletion_notice_date = models.DateTimeField(null=True, blank=True)
last_active_date = models.DateTimeField(null=True, blank=True)
objects = ProfileManager()
@ -76,7 +75,7 @@ class Profile(models.Model):
return settings.SITE_ROOT + path
def prepare_token(self, salt):
token = urlsafe_b64encode(os.urandom(24)).decode()
token = token_urlsafe(24)
self.token = make_password(token, salt)
self.save()
return token
@ -109,6 +108,13 @@ class Profile(models.Model):
ctx = {"button_text": "Change Email", "button_url": settings.SITE_ROOT + path}
emails.change_email(self.user.email, ctx)
def send_sms_limit_notice(self, transport):
ctx = {"transport": transport, "limit": self.sms_limit}
if self.sms_limit != 500 and settings.USE_PAYMENTS:
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
emails.sms_limit(self.user.email, ctx)
def projects(self):
""" Return a queryset of all projects we have access to. """
@ -166,7 +172,11 @@ class Profile(models.Model):
unsub_url = self.reports_unsub_url()
headers = {"List-Unsubscribe": unsub_url, "X-Bounce-Url": unsub_url}
headers = {
"List-Unsubscribe": "<%s>" % unsub_url,
"X-Bounce-Url": unsub_url,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
ctx = {
"checks": checks,
@ -229,21 +239,25 @@ class Project(models.Model):
return self.owner_profile.check_limit - num_used
def set_api_keys(self):
self.api_key = urlsafe_b64encode(os.urandom(24)).decode()
self.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode()
self.api_key = token_urlsafe(nbytes=24)
self.api_key_readonly = token_urlsafe(nbytes=24)
self.save()
def can_invite(self):
return self.member_set.count() < self.owner_profile.team_limit
def team(self):
return User.objects.filter(memberships__project=self).order_by("email")
def invite(self, user):
Member.objects.create(user=user, project=self)
def invite_suggestions(self):
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
q = q.exclude(memberships__project=self)
return q.distinct().order_by("email")
# Switch the invited user over to the new team so they
# notice the new team on next visit:
user.profile.current_project = self
user.profile.save()
def can_invite_new_users(self):
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
used = q.distinct().count()
return used < self.owner_profile.team_limit
def invite(self, user):
Member.objects.create(user=user, project=self)
user.profile.send_instant_login_link(self)
def set_next_nag_date(self):
@ -270,6 +284,16 @@ class Project(models.Model):
break
return status
def have_channel_issues(self):
errors = list(self.channel_set.values_list("last_error", flat=True))
# It's a problem if a project has no integrations at all
if len(errors) == 0:
return True
# It's a problem if any integration has a logged error
return True if max(errors) else False
class Member(models.Model):
user = models.ForeignKey(User, models.CASCADE, related_name="memberships")


+ 1
- 10
hc/accounts/tests/test_add_project.py View File

@ -2,12 +2,7 @@ from hc.accounts.models import Project
from hc.test import BaseTestCase
class RemoveProjectTestCase(BaseTestCase):
def setUp(self):
super(RemoveProjectTestCase, self).setUp()
self.url = "/projects/%s/remove/" % self.project.code
class AddProjectTestCase(BaseTestCase):
def test_it_works(self):
self.client.login(username="[email protected]", password="password")
r = self.client.post("/projects/add/", {"name": "My Second Project"})
@ -16,10 +11,6 @@ class RemoveProjectTestCase(BaseTestCase):
self.assertRedirects(r, "/projects/%s/checks/" % p.code)
self.assertEqual(str(p.code), p.badge_key)
# Alice's current project should be the just created one
self.profile.refresh_from_db()
self.assertEqual(self.profile.current_project, p)
def test_it_rejects_get(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/projects/add/")


+ 2
- 2
hc/accounts/tests/test_check_token.py View File

@ -40,9 +40,9 @@ class CheckTokenTestCase(BaseTestCase):
self.assertContains(r, "incorrect or expired")
def test_it_handles_next_parameter(self):
url = "/accounts/check_token/alice/secret-token/?next=/integrations/add_slack/"
url = "/accounts/check_token/alice/secret-token/?next=" + self.channels_url
r = self.client.post(url)
self.assertRedirects(r, "/integrations/add_slack/")
self.assertRedirects(r, self.channels_url)
def test_it_ignores_bad_next_parameter(self):
url = "/accounts/check_token/alice/secret-token/?next=/evil/"


+ 2
- 8
hc/accounts/tests/test_close_account.py View File

@ -1,8 +1,9 @@
from unittest.mock import patch
from django.contrib.auth.models import User
from hc.api.models import Check
from hc.payments.models import Subscription
from hc.test import BaseTestCase
from mock import patch
class CloseAccountTestCase(BaseTestCase):
@ -21,19 +22,12 @@ class CloseAccountTestCase(BaseTestCase):
alices = User.objects.filter(username="alice")
self.assertFalse(alices.exists())
# Bob's current team should now be None
self.bobs_profile.refresh_from_db()
self.assertIsNone(self.bobs_profile.current_project)
# Check should be gone
self.assertFalse(Check.objects.exists())
# Subscription should have been canceled
self.assertTrue(mock_braintree.Subscription.cancel.called)
# Braintree customer should have been deleted
self.assertTrue(mock_braintree.Customer.delete.called)
# Subscription should be gone
self.assertFalse(Subscription.objects.exists())


+ 4
- 3
hc/accounts/tests/test_login.py View File

@ -24,13 +24,14 @@ class LoginTestCase(BaseTestCase):
def test_it_sends_link_with_next(self):
form = {"identity": "[email protected]"}
r = self.client.post("/accounts/login/?next=/integrations/add_slack/", form)
r = self.client.post("/accounts/login/?next=" + self.channels_url, form)
self.assertRedirects(r, "/accounts/login_link_sent/")
self.assertIn("auto-login", r.cookies)
# The check_token link should have a ?next= query parameter:
self.assertEqual(len(mail.outbox), 1)
body = mail.outbox[0].body
self.assertTrue("/?next=/integrations/add_slack/" in body)
self.assertTrue("/?next=" + self.channels_url in body)
@override_settings(SECRET_KEY="test-secret")
def test_it_rate_limits_emails(self):
@ -84,7 +85,7 @@ class LoginTestCase(BaseTestCase):
form = {"action": "login", "email": "[email protected]", "password": "password"}
samples = ["/integrations/add_slack/", "/checks/%s/details/" % check.code]
samples = [self.channels_url, "/checks/%s/details/" % check.code]
for s in samples:
r = self.client.post("/accounts/login/?next=%s" % s, form)


+ 0
- 1
hc/accounts/tests/test_profile.py View File

@ -117,7 +117,6 @@ class ProfileTestCase(BaseTestCase):
self.assertNotContains(r, "Alice's Project")
self.bobs_profile.refresh_from_db()
self.assertIsNone(self.bobs_profile.current_project)
self.assertFalse(self.bob.memberships.exists())
def test_leaving_checks_membership(self):


+ 26
- 12
hc/accounts/tests/test_project.py View File

@ -3,7 +3,7 @@ from django.core import mail
from django.conf import settings
from django.test.utils import override_settings
from hc.test import BaseTestCase
from hc.accounts.models import Member
from hc.accounts.models import Member, Project
from hc.api.models import TokenBucket
@ -76,17 +76,37 @@ class ProjectTestCase(BaseTestCase):
project=self.project, user__email="[email protected]"
)
profile = member.user.profile
self.assertEqual(profile.current_project, self.project)
# The new user should not have their own project
self.assertFalse(member.user.project_set.exists())
# And an email should have been sent
subj = (
"You have been invited to join"
" Alice&#39;s Project on %s" % settings.SITE_NAME
" Alice's Project on %s" % settings.SITE_NAME
)
self.assertEqual(mail.outbox[0].subject, subj)
self.assertHTMLEqual(mail.outbox[0].subject, subj)
def test_it_adds_member_from_another_team(self):
# With team limit at zero, we should not be able to invite any new users
self.profile.team_limit = 0
self.profile.save()
# But Charlie will have an existing membership in another Alice's project
# so Alice *should* be able to invite Charlie:
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.charlie, project=p2)
self.client.login(username="[email protected]", password="password")
form = {"invite_team_member": "1", "email": "[email protected]"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
q = Member.objects.filter(project=self.project, user=self.charlie)
self.assertEqual(q.count(), 1)
# And this should not have affected the rate limit:
q = TokenBucket.objects.filter(value="invite-%d" % self.alice.id)
self.assertFalse(q.exists())
@override_settings(SECRET_KEY="test-secret")
def test_it_rate_limits_invites(self):
@ -126,10 +146,7 @@ class ProjectTestCase(BaseTestCase):
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
self.assertEqual(Member.objects.count(), 0)
self.bobs_profile.refresh_from_db()
self.assertEqual(self.bobs_profile.current_project, None)
self.assertFalse(Member.objects.exists())
def test_it_requires_owner_to_remove_team_member(self):
self.client.login(username="[email protected]", password="password")
@ -146,9 +163,6 @@ class ProjectTestCase(BaseTestCase):
r = self.client.post(url, form)
self.assertEqual(r.status_code, 400)
self.profile.refresh_from_db()
self.assertIsNotNone(self.profile.current_project)
def test_it_sets_project_name(self):
self.client.login(username="[email protected]", password="password")


+ 36
- 2
hc/accounts/tests/test_project_model.py View File

@ -1,6 +1,6 @@
from hc.test import BaseTestCase
from hc.accounts.models import Project
from hc.api.models import Check
from hc.accounts.models import Member, Project
from hc.api.models import Check, Channel
class ProjectModelTestCase(BaseTestCase):
@ -13,3 +13,37 @@ class ProjectModelTestCase(BaseTestCase):
Check.objects.create(project=p2)
self.assertEqual(self.project.num_checks_available(), 18)
def test_it_handles_zero_broken_channels(self):
Channel.objects.create(kind="webhook", last_error="", project=self.project)
self.assertFalse(self.project.have_channel_issues())
def test_it_handles_one_broken_channel(self):
Channel.objects.create(kind="webhook", last_error="x", project=self.project)
self.assertTrue(self.project.have_channel_issues())
def test_it_handles_no_channels(self):
# It's an issue if the project has no channels at all:
self.assertTrue(self.project.have_channel_issues())
def test_it_allows_third_user(self):
# Alice is the owner, and Bob is invited -- there is space for the third user:
self.assertTrue(self.project.can_invite_new_users())
def test_it_allows_same_user_in_multiple_projects(self):
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.bob, project=p2)
# Bob's membership in two projects counts as one seat,
# one seat should be still free:
self.assertTrue(self.project.can_invite_new_users())
def test_it_checks_team_limit(self):
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.charlie, project=p2)
# Alice and Bob are in one project, Charlie is in another,
# so no seats left:
self.assertFalse(self.project.can_invite_new_users())

+ 4
- 2
hc/accounts/tests/test_pruneusers.py View File

@ -1,4 +1,5 @@
from datetime import timedelta
from unittest.mock import Mock
from django.contrib.auth.models import User
from django.utils import timezone
@ -19,7 +20,7 @@ class PruneUsersTestCase(BaseTestCase):
charlies_project = Project.objects.create(owner=self.charlie)
Check(project=charlies_project).save()
Command().handle()
Command(stdout=Mock()).handle()
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
self.assertEqual(Check.objects.count(), 0)
@ -29,6 +30,7 @@ class PruneUsersTestCase(BaseTestCase):
self.bob.last_login = self.year_ago
self.bob.save()
Command().handle()
Command(stdout=Mock()).handle()
# Bob belongs to a team so should not get removed
self.assertEqual(User.objects.filter(username="bob").count(), 1)

+ 0
- 4
hc/accounts/tests/test_remove_project.py View File

@ -15,10 +15,6 @@ class RemoveProjectTestCase(BaseTestCase):
r = self.client.post(self.url)
self.assertRedirects(r, "/")
# Alice's current project should be not set
self.profile.refresh_from_db()
self.assertEqual(self.profile.current_project, None)
# Alice should not own any projects
self.assertFalse(self.alice.project_set.exists())


+ 106
- 0
hc/accounts/tests/test_senddeletionnotices.py View File

@ -0,0 +1,106 @@
from datetime import timedelta as td
from unittest.mock import Mock
from django.core import mail
from django.utils.timezone import now
from hc.accounts.management.commands.senddeletionnotices import Command
from hc.accounts.models import Member
from hc.api.models import Check, Ping
from hc.test import BaseTestCase
class SendDeletionNoticesTestCase(BaseTestCase):
def setUp(self):
super(SendDeletionNoticesTestCase, self).setUp()
# Make alice eligible for notice -- signed up more than 1 year ago
self.alice.date_joined = now() - td(days=500)
self.alice.save()
self.profile.sms_limit = 5
self.profile.save()
# remove members from alice's project
self.project.member_set.all().delete()
def test_it_sends_notice(self):
cmd = Command(stdout=Mock())
cmd.pause = Mock() # don't pause for 1s
result = cmd.handle()
self.assertEqual(result, "Done! Sent 1 notices")
self.profile.refresh_from_db()
self.assertTrue(self.profile.deletion_notice_date)
email = mail.outbox[0]
self.assertEqual(email.subject, "Inactive Account Notification")
def test_it_checks_last_login(self):
# alice has logged in recently:
self.alice.last_login = now() - td(days=15)
self.alice.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(result, "Done! Sent 0 notices")
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_date_joined(self):
# alice signed up recently:
self.alice.date_joined = now() - td(days=15)
self.alice.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(result, "Done! Sent 0 notices")
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_deletion_notice_date(self):
# alice has already received a deletion notice
self.profile.deletion_notice_date = now() - td(days=15)
self.profile.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(result, "Done! Sent 0 notices")
def test_it_checks_sms_limit(self):
# alice has a paid account
self.profile.sms_limit = 50
self.profile.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(result, "Done! Sent 0 notices")
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_team_members(self):
# bob has access to alice's project
Member.objects.create(user=self.bob, project=self.project)
result = Command(stdout=Mock()).handle()
self.assertEqual(result, "Done! Sent 0 notices")
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_recent_pings(self):
check = Check.objects.create(project=self.project)
Ping.objects.create(owner=check)
result = Command(stdout=Mock()).handle()
self.assertEqual(result, "Done! Sent 0 notices")
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_last_active_date(self):
# alice has been browsing the site recently
self.profile.last_active_date = now() - td(days=15)
self.profile.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(result, "Done! Sent 0 notices")

+ 1
- 0
hc/accounts/tests/test_signup.py View File

@ -13,6 +13,7 @@ class SignupTestCase(TestCase):
r = self.client.post("/accounts/signup/", form)
self.assertContains(r, "Account created")
self.assertIn("auto-login", r.cookies)
# An user should have been created
user = User.objects.get()


+ 15
- 7
hc/accounts/tests/test_unsubscribe_reports.py View File

@ -1,4 +1,6 @@
from datetime import timedelta as td
import time
from unittest.mock import patch
from django.core import signing
from django.utils.timezone import now
@ -15,7 +17,7 @@ class UnsubscribeReportsTestCase(BaseTestCase):
sig = signing.TimestampSigner(salt="reports").sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.get(url)
r = self.client.post(url)
self.assertContains(r, "Unsubscribed")
self.profile.refresh_from_db()
@ -30,16 +32,22 @@ class UnsubscribeReportsTestCase(BaseTestCase):
r = self.client.get(url)
self.assertContains(r, "Incorrect Link")
def test_post_works(self):
def test_it_serves_confirmation_form(self):
sig = signing.TimestampSigner(salt="reports").sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.post(url)
self.assertContains(r, "Unsubscribed")
r = self.client.get(url)
self.assertContains(r, "Please press the button below")
self.assertNotContains(r, "submit()")
def test_it_serves_confirmation_form(self):
sig = signing.TimestampSigner(salt="reports").sign("alice")
url = "/accounts/unsubscribe_reports/%s/?ask=1" % sig
def test_aged_signature_autosubmits(self):
with patch("django.core.signing.time") as mock_time:
mock_time.time.return_value = time.time() - 301
signer = signing.TimestampSigner(salt="reports")
sig = signer.sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.get(url)
self.assertContains(r, "Please press the button below")
self.assertContains(r, "submit()")

+ 1
- 1
hc/accounts/urls.py View File

@ -16,7 +16,7 @@ urlpatterns = [
path("profile/notifications/", views.notifications, name="hc-notifications"),
path("close/", views.close, name="hc-close"),
path(
"unsubscribe_reports/<str:username>/",
"unsubscribe_reports/<str:signed_username>/",
views.unsubscribe_reports,
name="hc-unsubscribe-reports",
),


+ 50
- 38
hc/accounts/views.py View File

@ -1,4 +1,5 @@
from datetime import timedelta as td
from urllib.parse import urlparse
import uuid
from django.conf import settings
@ -32,23 +33,27 @@ from hc.accounts.forms import (
)
from hc.accounts.models import Profile, Project, Member
from hc.api.models import Channel, Check, TokenBucket
from hc.lib.date import choose_next_report_date
from hc.payments.models import Subscription
NEXT_WHITELIST = (
"hc-checks",
"hc-details",
"hc-log",
"hc-channels",
"hc-p-channels",
"hc-add-slack",
"hc-add-pushover",
"hc-add-telegram",
)
NAMESPACE_HC = uuid.UUID("2b25afdf-ce1a-4fa3-adf2-592e35f27fa9")
def _is_whitelisted(redirect_url):
if not redirect_url:
return False
def _is_whitelisted(path):
parsed = urlparse(redirect_url)
try:
match = resolve(path)
match = resolve(parsed.path)
except Resolver404:
return False
@ -56,10 +61,7 @@ def _is_whitelisted(path):
def _make_user(email, with_project=True):
# Generate username from email in a deterministic way.
# Since the database has an uniqueness constraint on username,
# this makes sure that emails also are unique.
username = str(uuid.uuid3(NAMESPACE_HC, email))
username = str(uuid.uuid4())[:30]
user = User(username=username, email=email)
user.set_unusable_password()
user.save()
@ -83,9 +85,7 @@ def _make_user(email, with_project=True):
channel.checks.add(check)
# Ensure a profile gets created
profile = Profile.objects.for_user(user)
profile.current_project = project
profile.save()
Profile.objects.for_user(user)
return user
@ -148,6 +148,7 @@ def logout(request):
@require_POST
@csrf_exempt
def signup(request):
if not settings.REGISTRATION_OPEN:
return HttpResponseForbidden()
@ -163,7 +164,11 @@ def signup(request):
else:
ctx = {"form": form}
return render(request, "accounts/signup_result.html", ctx)
response = render(request, "accounts/signup_result.html", ctx)
if ctx.get("created"):
response.set_cookie("auto-login", "1", max_age=300, httponly=True)
return response
def login_link_sent(request):
@ -221,10 +226,6 @@ def profile(request):
except Project.DoesNotExist:
return HttpResponseBadRequest()
if profile.current_project == project:
profile.current_project = None
profile.save()
Member.objects.filter(project=project, user=request.user).delete()
ctx["left_project"] = project
@ -264,6 +265,7 @@ def project(request, code):
return HttpResponseNotFound()
is_owner = project.owner_id == request.user.id
invite_suggestions = project.invite_suggestions()
ctx = {
"page": "project",
"project": project,
@ -272,6 +274,7 @@ def project(request, code):
"project_name_status": "default",
"api_status": "default",
"team_status": "default",
"invite_suggestions": invite_suggestions,
}
if request.method == "POST":
@ -292,15 +295,22 @@ def project(request, code):
elif "show_api_keys" in request.POST:
ctx["show_api_keys"] = True
elif "invite_team_member" in request.POST:
if not is_owner or not project.can_invite():
if not is_owner:
return HttpResponseForbidden()
form = InviteTeamMemberForm(request.POST)
if form.is_valid():
if not TokenBucket.authorize_invite(request.user):
return render(request, "try_later.html")
email = form.cleaned_data["email"]
if not invite_suggestions.filter(email=email).exists():
# We're inviting a new user. Are we within team size limit?
if not project.can_invite_new_users():
return HttpResponseForbidden()
# And are we not hitting a rate limit?
if not TokenBucket.authorize_invite(request.user):
return render(request, "try_later.html")
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
@ -323,9 +333,6 @@ def project(request, code):
if farewell_user is None:
return HttpResponseBadRequest()
farewell_user.profile.current_project = None
farewell_user.profile.save()
Member.objects.filter(project=project, user=farewell_user).delete()
ctx["team_member_removed"] = form.cleaned_data["email"]
@ -336,15 +343,9 @@ def project(request, code):
project.name = form.cleaned_data["name"]
project.save()
if request.profile.current_project == project:
request.profile.current_project.name = project.name
ctx["project_name_updated"] = True
ctx["project_name_status"] = "success"
# Count members right before rendering the template, in case
# we just invited or removed someone
ctx["num_members"] = project.member_set.count()
return render(request, "accounts/project.html", ctx)
@ -360,7 +361,7 @@ def notifications(request):
if profile.reports_allowed != form.cleaned_data["reports_allowed"]:
profile.reports_allowed = form.cleaned_data["reports_allowed"]
if profile.reports_allowed:
profile.next_report_date = now() + td(days=30)
profile.next_report_date = choose_next_report_date()
else:
profile.next_report_date = None
@ -432,17 +433,28 @@ def change_email_done(request):
@csrf_exempt
def unsubscribe_reports(request, username):
def unsubscribe_reports(request, signed_username):
# Some email servers open links in emails to check for malicious content.
# To work around this, for GET requests we serve a confirmation form.
# If the signature is more than 5 minutes old, we also include JS code to
# auto-submit the form.
ctx = {}
signer = signing.TimestampSigner(salt="reports")
# First, check the signature without looking at the timestamp:
try:
username = signer.unsign(username)
username = signer.unsign(signed_username)
except signing.BadSignature:
return render(request, "bad_link.html")
# Some email servers open links in emails to check for malicious content.
# To work around this, we serve a form that auto-submits with JS.
if "ask" in request.GET and request.method != "POST":
return render(request, "accounts/unsubscribe_submit.html")
# Check if timestamp is older than 5 minutes:
try:
username = signer.unsign(signed_username, max_age=300)
except signing.SignatureExpired:
ctx["autosubmit"] = True
if request.method != "POST":
return render(request, "accounts/unsubscribe_submit.html", ctx)
user = User.objects.get(username=username)
profile = Profile.objects.for_user(user)
@ -460,10 +472,10 @@ def unsubscribe_reports(request, username):
def close(request):
user = request.user
# Subscription needs to be canceled before it is deleted:
# Cancel their subscription:
sub = Subscription.objects.filter(user=user).first()
if sub:
sub.cancel(delete_customer=True)
sub.cancel()
user.delete()


+ 2
- 0
hc/api/admin.py View File

@ -201,6 +201,7 @@ class ChannelsAdmin(admin.ModelAdmin):
@admin.register(Notification)
class NotificationsAdmin(admin.ModelAdmin):
search_fields = ["owner__name", "owner__code", "channel__value"]
readonly_fields = ("owner",)
list_select_related = ("owner", "channel")
list_display = (
"id",
@ -209,6 +210,7 @@ class NotificationsAdmin(admin.ModelAdmin):
"owner",
"channel_kind",
"channel_value",
"error",
)
list_filter = ("created", "check_status", "channel__kind")


+ 25
- 6
hc/api/management/commands/sendalerts.py View File

@ -1,9 +1,14 @@
from datetime import timedelta as td
import time
from threading import Thread
from django.core.management.base import BaseCommand
from django.utils import timezone
from hc.api.models import Check, Flip
from statsd.defaults.env import statsd
SENDING_TMPL = "Sending alert, status=%s, code=%s\n"
SEND_TIME_TMPL = "Sending took %.1fs, code=%s\n"
def notify(flip_id, stdout):
@ -16,18 +21,26 @@ def notify(flip_id, stdout):
# And just to make sure it doesn't get saved by a future coding accident:
setattr(check, "save", None)
tmpl = "Sending alert, status=%s, code=%s\n"
stdout.write(tmpl % (flip.new_status, check.code))
stdout.write(SENDING_TMPL % (flip.new_status, check.code))
# Set dates for followup nags
if flip.new_status == "down":
check.project.set_next_nag_date()
# Send notifications
send_start = timezone.now()
errors = flip.send_alerts()
for ch, error in errors:
stdout.write("ERROR: %s %s %s\n" % (ch.kind, ch.value, error))
# If sending took more than 5s, log it
send_time = timezone.now() - send_start
if send_time.total_seconds() > 5:
stdout.write(SEND_TIME_TMPL % (send_time.total_seconds(), check.code))
statsd.timing("hc.sendalerts.dwellTime", send_start - flip.created)
statsd.timing("hc.sendalerts.sendTime", send_time)
def notify_on_thread(flip_id, stdout):
t = Thread(target=notify, args=(flip_id, stdout))
@ -82,9 +95,6 @@ class Command(BaseCommand):
now = timezone.now()
# In PostgreSQL, add this index to run the below query efficiently:
# CREATE INDEX api_check_up ON api_check (alert_after) WHERE status = 'up'
q = Check.objects.filter(alert_after__lt=now).exclude(status="down")
# Sort by alert_after, to avoid unnecessary sorting by id:
check = q.order_by("alert_after").first()
@ -94,7 +104,16 @@ class Command(BaseCommand):
old_status = check.status
q = Check.objects.filter(id=check.id, status=old_status)
if check.get_status(with_started=False) != "down":
try:
status = check.get_status(with_started=False)
except Exception as e:
# Make sure we don't trip on this check again for an hour:
# Otherwise sendalerts may end up in a crash loop.
q.update(alert_after=now + td(hours=1))
# Then re-raise the exception:
raise e
if status != "down":
# It is not down yet. Update alert_after
q.update(alert_after=check.going_down_after())
return True


+ 10
- 9
hc/api/management/commands/sendreports.py View File

@ -1,4 +1,3 @@
from datetime import timedelta
import time
from django.core.management.base import BaseCommand
@ -6,6 +5,7 @@ from django.db.models import Q
from django.utils import timezone
from hc.accounts.models import NO_NAG, Profile
from hc.api.models import Check
from hc.lib.date import choose_next_report_date
def num_pinged_checks(profile):
@ -31,28 +31,29 @@ class Command(BaseCommand):
)
def handle_one_monthly_report(self):
now = timezone.now()
month_before = now - timedelta(days=30)
month_after = now + timedelta(days=30)
report_due = Q(next_report_date__lt=now)
report_due = Q(next_report_date__lt=timezone.now())
report_not_scheduled = Q(next_report_date__isnull=True)
q = Profile.objects.filter(report_due | report_not_scheduled)
q = q.filter(reports_allowed=True)
q = q.filter(user__date_joined__lt=month_before)
profile = q.first()
if profile is None:
# No matching profiles found – nothing to do right now.
return False
# A sort of optimistic lock. Try to update next_report_date,
# A sort of optimistic lock. Will try to update next_report_date,
# and if does get modified, we're in drivers seat:
qq = Profile.objects.filter(
id=profile.id, next_report_date=profile.next_report_date
)
num_updated = qq.update(next_report_date=month_after)
# Next report date is currently not scheduled: schedule it and move on.
if profile.next_report_date is None:
qq.update(next_report_date=choose_next_report_date())
return True
num_updated = qq.update(next_report_date=choose_next_report_date())
if num_updated != 1:
# next_report_date was already updated elsewhere, skipping
return True


+ 18
- 0
hc/api/migrations/0064_auto_20191119_1346.py View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.6 on 2019-11-19 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0063_auto_20190903_0901'),
]
operations = [
migrations.AlterField(
model_name='channel',
name='kind',
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp'), ('apprise', 'Apprise'), ('mattermost', 'Mattermost'), ('msteams', 'Microsoft Teams')], max_length=20),
),
]

+ 23
- 0
hc/api/migrations/0065_auto_20191127_1240.py View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.6 on 2019-11-27 12:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0064_auto_20191119_1346'),
]
operations = [
migrations.AddField(
model_name='check',
name='methods',
field=models.CharField(blank=True, max_length=30),
),
migrations.AlterField(
model_name='channel',
name='kind',
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp'), ('apprise', 'Apprise'), ('mattermost', 'Mattermost'), ('msteams', 'Microsoft Teams'), ('shell', 'Shell Command')], max_length=20),
),
]

+ 18
- 0
hc/api/migrations/0066_channel_last_error.py View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.1 on 2020-01-02 12:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0065_auto_20191127_1240'),
]
operations = [
migrations.AddField(
model_name='channel',
name='last_error',
field=models.CharField(blank=True, max_length=200),
),
]

+ 27
- 0
hc/api/migrations/0067_last_error_values.py View File

@ -0,0 +1,27 @@
# Generated by Django 3.0.1 on 2020-01-02 14:28
from django.db import migrations
def fill_last_errors(apps, schema_editor):
Channel = apps.get_model("api", "Channel")
Notification = apps.get_model("api", "Notification")
for ch in Channel.objects.all():
error = ""
try:
n = Notification.objects.filter(channel=ch).latest()
error = n.error
except Notification.DoesNotExist:
pass
ch.last_error = error
ch.save()
class Migration(migrations.Migration):
dependencies = [
("api", "0066_channel_last_error"),
]
operations = [migrations.RunPython(fill_last_errors, migrations.RunPython.noop)]

+ 18
- 0
hc/api/migrations/0068_auto_20200117_1023.py View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.1 on 2020-01-17 10:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0067_last_error_values'),
]
operations = [
migrations.AlterField(
model_name='ping',
name='body',
field=models.TextField(blank=True, null=True),
),
]

+ 19
- 0
hc/api/migrations/0069_auto_20200117_1227.py View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.1 on 2020-01-17 12:27
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('api', '0068_auto_20200117_1023'),
]
operations = [
migrations.AlterField(
model_name='ping',
name='created',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

+ 109
- 42
hc/api/models.py View File

@ -7,6 +7,7 @@ from datetime import datetime, timedelta as td
from croniter import croniter
from django.conf import settings
from django.core.signing import TimestampSigner
from django.db import models
from django.urls import reverse
from django.utils import timezone
@ -45,6 +46,9 @@ CHANNEL_KINDS = (
("whatsapp", "WhatsApp"),
("apprise", "Apprise"),
("mattermost", "Mattermost"),
("msteams", "Microsoft Teams"),
("shell", "Shell Command"),
("zulip", "Zulip"),
)
PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"}
@ -70,6 +74,8 @@ class Check(models.Model):
schedule = models.CharField(max_length=100, default="* * * * *")
tz = models.CharField(max_length=36, default="UTC")
subject = models.CharField(max_length=100, blank=True)
methods = models.CharField(max_length=30, blank=True)
n_pings = models.IntegerField(default=0)
last_ping = models.DateTimeField(null=True, blank=True)
last_start = models.DateTimeField(null=True, blank=True)
@ -194,6 +200,11 @@ class Check(models.Model):
codes = self.channel_set.order_by("code").values_list("code", flat=True)
return ",".join(map(str, codes))
@property
def unique_key(self):
code_half = self.code.hex[:16]
return hashlib.sha1(code_half.encode()).hexdigest()
def to_dict(self, readonly=False):
result = {
@ -211,10 +222,9 @@ class Check(models.Model):
result["last_duration"] = int(self.last_duration.total_seconds())
if readonly:
code_half = self.code.hex[:16]
result["unique_key"] = hashlib.sha1(code_half.encode()).hexdigest()
result["unique_key"] = self.unique_key
else:
update_rel_url = reverse("hc-api-update", args=[self.code])
update_rel_url = reverse("hc-api-single", args=[self.code])
pause_rel_url = reverse("hc-api-pause", args=[self.code])
result["ping_url"] = self.url()
@ -231,13 +241,15 @@ class Check(models.Model):
return result
def ping(self, remote_addr, scheme, method, ua, body, action):
now = timezone.now()
if action == "start":
self.last_start = timezone.now()
self.last_start = now
# Don't update "last_ping" field.
elif action == "ign":
pass
else:
self.last_ping = timezone.now()
self.last_ping = now
if self.last_start:
self.last_duration = self.last_ping - self.last_start
self.last_start = None
@ -262,6 +274,7 @@ class Check(models.Model):
ping = Ping(owner=self)
ping.n = self.n_pings
ping.created = now
if action in ("start", "fail", "ign"):
ping.kind = action
@ -270,10 +283,10 @@ class Check(models.Model):
ping.method = method
# If User-Agent is longer than 200 characters, truncate it:
ping.ua = ua[:200]
ping.body = body[:10000]
ping.body = body[: settings.PING_BODY_LIMIT]
ping.save()
def downtimes(self, months=2):
def downtimes(self, months=3):
""" Calculate the number of downtimes and downtime minutes per month.
Returns a list of (datetime, downtime_in_secs, number_of_outages) tuples.
@ -316,13 +329,13 @@ class Ping(models.Model):
id = models.BigAutoField(primary_key=True)
n = models.IntegerField(null=True)
owner = models.ForeignKey(Check, models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
created = models.DateTimeField(default=timezone.now)
kind = models.CharField(max_length=6, blank=True, null=True)
scheme = models.CharField(max_length=10, default="http")
remote_addr = models.GenericIPAddressField(blank=True, null=True)
method = models.CharField(max_length=10, blank=True)
ua = models.CharField(max_length=200, blank=True)
body = models.CharField(max_length=10000, blank=True, null=True)
body = models.TextField(blank=True, null=True)
class Channel(models.Model):
@ -333,6 +346,7 @@ class Channel(models.Model):
kind = models.CharField(max_length=20, choices=CHANNEL_KINDS)
value = models.TextField(blank=True)
email_verified = models.BooleanField(default=False)
last_error = models.CharField(max_length=200, blank=True)
checks = models.ManyToManyField(Check)
def __str__(self):
@ -346,6 +360,11 @@ class Channel(models.Model):
return "Slack %s" % self.slack_channel
elif self.kind == "telegram":
return "Telegram %s" % self.telegram_name
elif self.kind == "zulip":
if self.zulip_type == "stream":
return "Zulip stream %s" % self.zulip_to
if self.zulip_type == "private":
return "Zulip user %s" % self.zulip_to
return self.get_kind_display()
@ -368,7 +387,9 @@ class Channel(models.Model):
emails.verify_email(self.email_value, {"verify_link": verify_link})
def get_unsub_link(self):
args = [self.code, self.make_token()]
signer = TimestampSigner(salt="alerts")
signed_token = signer.sign(self.make_token())
args = [self.code, signed_token]
verify_link = reverse("hc-unsubscribe-alerts", args=args)
return settings.SITE_ROOT + verify_link
@ -410,6 +431,12 @@ class Channel(models.Model):
return transports.WhatsApp(self)
elif self.kind == "apprise":
return transports.Apprise(self)
elif self.kind == "msteams":
return transports.MsTeams(self)
elif self.kind == "shell":
return transports.Shell(self)
elif self.kind == "zulip":
return transports.Zulip(self)
else:
raise NotImplementedError("Unknown channel kind: %s" % self.kind)
@ -430,11 +457,18 @@ class Channel(models.Model):
n.error = error
n.save()
self.last_error = error
self.save()
return error
def icon_path(self):
return "img/integrations/%s.png" % self.kind
@property
def json(self):
return json.loads(self.value)
@property
def po_priority(self):
assert self.kind == "po"
@ -445,29 +479,7 @@ class Channel(models.Model):
def webhook_spec(self, status):
assert self.kind == "webhook"
if not self.value.startswith("{"):
parts = self.value.split("\n")
url_down = parts[0]
url_up = parts[1] if len(parts) > 1 else ""
post_data = parts[2] if len(parts) > 2 else ""
return {
"method": "POST" if post_data else "GET",
"url": url_down if status == "down" else url_up,
"body": post_data,
"headers": {},
}
doc = json.loads(self.value)
if "post_data" in doc:
# Legacy "post_data" in doc -- use the legacy fields
return {
"method": "POST" if doc["post_data"] else "GET",
"url": doc["url_down"] if status == "down" else doc["url_up"],
"body": doc["post_data"],
"headers": doc["headers"],
}
if status == "down" and "method_down" in doc:
return {
"method": doc["method_down"],
@ -499,6 +511,16 @@ class Channel(models.Model):
def url_up(self):
return self.up_webhook_spec["url"]
@property
def cmd_down(self):
assert self.kind == "shell"
return self.json["cmd_down"]
@property
def cmd_up(self):
assert self.kind == "shell"
return self.json["cmd_up"]
@property
def slack_team(self):
assert self.kind == "slack"
@ -506,7 +528,11 @@ class Channel(models.Model):
return None
doc = json.loads(self.value)
return doc["team_name"]
if "team_name" in doc:
return doc["team_name"]
if "team" in doc:
return doc["team"]["name"]
@property
def slack_channel(self):
@ -583,13 +609,6 @@ class Channel(models.Model):
return doc["value"]
return self.value
@property
def sms_label(self):
assert self.kind == "sms"
if self.value.startswith("{"):
doc = json.loads(self.value)
return doc["label"]
@property
def trello_token(self):
assert self.kind == "trello"
@ -617,8 +636,7 @@ class Channel(models.Model):
if not self.value.startswith("{"):
return self.value
doc = json.loads(self.value)
return doc.get("value")
return self.json["value"]
@property
def email_notify_up(self):
@ -650,6 +668,48 @@ class Channel(models.Model):
doc = json.loads(self.value)
return doc["down"]
@property
def opsgenie_key(self):
assert self.kind == "opsgenie"
if not self.value.startswith("{"):
return self.value
doc = json.loads(self.value)
return doc["key"]
@property
def opsgenie_region(self):
assert self.kind == "opsgenie"
if not self.value.startswith("{"):
return "us"
doc = json.loads(self.value)
return doc["region"]
@property
def zulip_bot_email(self):
assert self.kind == "zulip"
doc = json.loads(self.value)
return doc["bot_email"]
@property
def zulip_api_key(self):
assert self.kind == "zulip"
doc = json.loads(self.value)
return doc["api_key"]
@property
def zulip_type(self):
assert self.kind == "zulip"
doc = json.loads(self.value)
return doc["mtype"]
@property
def zulip_to(self):
assert self.kind == "zulip"
doc = json.loads(self.value)
return doc["to"]
class Notification(models.Model):
class Meta:
@ -757,3 +817,10 @@ class TokenBucket(models.Model):
# 20 password attempts per day
return TokenBucket.authorize(value, 20, 3600 * 24)
@staticmethod
def authorize_telegram(telegram_id):
value = "tg-%s" % telegram_id
# 10 messages for a single chat per minute:
return TokenBucket.authorize(value, 10, 60)

+ 1
- 0
hc/api/schemas.py View File

@ -2,6 +2,7 @@ check = {
"type": "object",
"properties": {
"name": {"type": "string", "maxLength": 100},
"desc": {"type": "string"},
"tags": {"type": "string", "maxLength": 500},
"timeout": {"type": "number", "minimum": 60, "maximum": 2592000},
"grace": {"type": "number", "minimum": 60, "maximum": 2592000},


+ 4
- 0
hc/api/tests/test_badge.py View File

@ -27,6 +27,10 @@ class BadgeTestCase(BaseTestCase):
self.assertEqual(r["Access-Control-Allow-Origin"], "*")
self.assertContains(r, "#4c1")
def test_it_rejects_bad_format(self):
r = self.client.get(self.json_url + "foo")
self.assertEqual(r.status_code, 404)
def test_it_handles_options(self):
r = self.client.options(self.svg_url)
self.assertEqual(r.status_code, 204)


+ 16
- 0
hc/api/tests/test_bounce.py View File

@ -29,6 +29,7 @@ class BounceTestCase(BaseTestCase):
self.channel.refresh_from_db()
self.assertFalse(self.channel.email_verified)
self.assertEqual(self.channel.last_error, "foo")
def test_it_checks_ttl(self):
self.n.created = self.n.created - timedelta(minutes=60)
@ -49,3 +50,18 @@ class BounceTestCase(BaseTestCase):
url = "/api/v1/notifications/%s/bounce" % fake_code
r = self.client.post(url, "", content_type="text/plain")
self.assertEqual(r.status_code, 404)
def test_it_requires_post(self):
url = "/api/v1/notifications/%s/bounce" % self.n.code
r = self.client.get(url)
self.assertEqual(r.status_code, 405)
def test_does_not_unsubscribe_transient_bounces(self):
url = "/api/v1/notifications/%s/bounce?type=Transient" % self.n.code
self.client.post(url, "foo", content_type="text/plain")
self.n.refresh_from_db()
self.assertEqual(self.n.error, "foo")
self.channel.refresh_from_db()
self.assertTrue(self.channel.email_verified)

+ 11
- 110
hc/api/tests/test_channel_model.py View File

@ -5,116 +5,6 @@ from hc.test import BaseTestCase
class ChannelModelTestCase(BaseTestCase):
def test_webhook_spec_handles_plain_single_address(self):
c = Channel(kind="webhook")
c.value = "http://example.org"
self.assertEqual(
c.down_webhook_spec,
{"method": "GET", "url": "http://example.org", "body": "", "headers": {}},
)
self.assertEqual(
c.up_webhook_spec, {"method": "GET", "url": "", "body": "", "headers": {}}
)
def test_webhook_spec_handles_plain_pair(self):
c = Channel(kind="webhook")
c.value = "http://example.org\nhttp://example.com/up/"
self.assertEqual(
c.down_webhook_spec,
{"method": "GET", "url": "http://example.org", "body": "", "headers": {}},
)
self.assertEqual(
c.up_webhook_spec,
{
"method": "GET",
"url": "http://example.com/up/",
"body": "",
"headers": {},
},
)
def test_webhook_spec_handles_plain_post(self):
c = Channel(kind="webhook")
c.value = "http://example.org\n\nhello world"
self.assertEqual(
c.down_webhook_spec,
{
"method": "POST",
"url": "http://example.org",
"body": "hello world",
"headers": {},
},
)
self.assertEqual(
c.up_webhook_spec,
{"method": "POST", "url": "", "body": "hello world", "headers": {}},
)
def test_webhook_spec_handles_legacy_get(self):
c = Channel(kind="webhook")
c.value = json.dumps(
{
"url_down": "http://example.org",
"url_up": "http://example.org/up/",
"headers": {"X-Name": "value"},
"post_data": "",
}
)
self.assertEqual(
c.down_webhook_spec,
{
"method": "GET",
"url": "http://example.org",
"body": "",
"headers": {"X-Name": "value"},
},
)
self.assertEqual(
c.up_webhook_spec,
{
"method": "GET",
"url": "http://example.org/up/",
"body": "",
"headers": {"X-Name": "value"},
},
)
def test_webhook_spec_handles_legacy_post(self):
c = Channel(kind="webhook")
c.value = json.dumps(
{
"url_down": "http://example.org",
"url_up": "http://example.org/up/",
"headers": {"X-Name": "value"},
"post_data": "hello world",
}
)
self.assertEqual(
c.down_webhook_spec,
{
"method": "POST",
"url": "http://example.org",
"body": "hello world",
"headers": {"X-Name": "value"},
},
)
self.assertEqual(
c.up_webhook_spec,
{
"method": "POST",
"url": "http://example.org/up/",
"body": "hello world",
"headers": {"X-Name": "value"},
},
)
def test_webhook_spec_handles_mixed(self):
c = Channel(kind="webhook")
c.value = json.dumps(
@ -149,3 +39,14 @@ class ChannelModelTestCase(BaseTestCase):
"headers": {"X-Status": "OK"},
},
)
def test_it_handles_legacy_opsgenie_value(self):
c = Channel(kind="opsgenie", value="foo123")
self.assertEqual(c.opsgenie_key, "foo123")
self.assertEqual(c.opsgenie_region, "us")
def test_it_handles_json_opsgenie_value(self):
c = Channel(kind="opsgenie")
c.value = json.dumps({"key": "abc", "region": "eu"})
self.assertEqual(c.opsgenie_key, "abc")
self.assertEqual(c.opsgenie_region, "eu")

+ 1
- 1
hc/api/tests/test_check_model.py View File

@ -1,9 +1,9 @@
from datetime import datetime, timedelta
from unittest.mock import patch
from django.utils import timezone
from hc.api.models import Check, Flip
from hc.test import BaseTestCase
from mock import patch
class CheckModelTestCase(BaseTestCase):


+ 9
- 1
hc/api/tests/test_create_check.py View File

@ -84,6 +84,14 @@ class CreateCheckTestCase(BaseTestCase):
check = Check.objects.get()
self.assertEqual(check.channel_set.get(), channel)
def test_it_rejects_bad_channel_code(self):
r = self.post({"api_key": "X" * 32, "channels": "abc"})
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json()["error"], "invalid channel identifier: abc")
# The check should not have been saved
self.assertFalse(Check.objects.exists())
def test_it_supports_unique(self):
Check.objects.create(project=self.project, name="Foo")
@ -211,7 +219,7 @@ class CreateCheckTestCase(BaseTestCase):
r = self.post({"api_key": "X" * 32})
self.assertEqual(r.status_code, 403)
def test_readonly_key_does_not_work(self):
def test_it_rejects_readonly_key(self):
self.project.api_key_readonly = "R" * 32
self.project.save()


+ 64
- 0
hc/api/tests/test_get_check.py View File

@ -0,0 +1,64 @@
from datetime import timedelta as td
from django.utils.timezone import now
from hc.api.models import Channel, Check
from hc.test import BaseTestCase
class GetCheckTestCase(BaseTestCase):
def setUp(self):
super(GetCheckTestCase, self).setUp()
self.now = now().replace(microsecond=0)
self.a1 = Check(project=self.project, name="Alice 1")
self.a1.timeout = td(seconds=3600)
self.a1.grace = td(seconds=900)
self.a1.n_pings = 0
self.a1.status = "new"
self.a1.tags = "a1-tag a1-additional-tag"
self.a1.desc = "This is description"
self.a1.save()
self.c1 = Channel.objects.create(project=self.project)
self.a1.channel_set.add(self.c1)
def get(self, code, api_key="X" * 32):
url = "/api/v1/checks/%s" % code
return self.client.get(url, HTTP_X_API_KEY=api_key)
def test_it_works(self):
r = self.get(self.a1.code)
self.assertEqual(r.status_code, 200)
self.assertEqual(r["Access-Control-Allow-Origin"], "*")
doc = r.json()
self.assertEqual(len(doc), 13)
self.assertEqual(doc["timeout"], 3600)
self.assertEqual(doc["grace"], 900)
self.assertEqual(doc["ping_url"], self.a1.url())
self.assertEqual(doc["last_ping"], None)
self.assertEqual(doc["n_pings"], 0)
self.assertEqual(doc["status"], "new")
self.assertEqual(doc["channels"], str(self.c1.code))
self.assertEqual(doc["desc"], "This is description")
def test_it_handles_invalid_uuid(self):
r = self.get("not-an-uuid")
self.assertEqual(r.status_code, 404)
def test_it_handles_missing_check(self):
made_up_code = "07c2f548-9850-4b27-af5d-6c9dc157ec02"
r = self.get(made_up_code)
self.assertEqual(r.status_code, 404)
def test_readonly_key_works(self):
self.project.api_key_readonly = "R" * 32
self.project.save()
r = self.get(self.a1.code, api_key=self.project.api_key_readonly)
self.assertEqual(r.status_code, 200)
# When using readonly keys, the ping URLs should not be exposed:
self.assertNotContains(r, self.a1.url())

+ 262
- 63
hc/api/tests/test_notify.py View File

@ -2,12 +2,12 @@
from datetime import timedelta as td
import json
from unittest.mock import patch, Mock
from django.core import mail
from django.utils.timezone import now
from hc.api.models import Channel, Check, Notification
from hc.api.models import Channel, Check, Notification, TokenBucket
from hc.test import BaseTestCase
from mock import patch, Mock
from requests.exceptions import ConnectionError, Timeout
from django.test.utils import override_settings
@ -28,7 +28,14 @@ class NotifyTestCase(BaseTestCase):
@patch("hc.api.transports.requests.request")
def test_webhook(self, mock_get):
self._setup_data("webhook", "http://example")
definition = {
"method_down": "GET",
"url_down": "http://example",
"body_down": "",
"headers_down": {},
}
self._setup_data("webhook", json.dumps(definition))
mock_get.return_value.status_code = 200
self.channel.notify(self.check)
@ -41,31 +48,44 @@ class NotifyTestCase(BaseTestCase):
@patch("hc.api.transports.requests.request", side_effect=Timeout)
def test_webhooks_handle_timeouts(self, mock_get):
self._setup_data("webhook", "http://example")
definition = {
"method_down": "GET",
"url_down": "http://example",
"body_down": "",
"headers_down": {},
}
self._setup_data("webhook", json.dumps(definition))
self.channel.notify(self.check)
n = Notification.objects.get()
self.assertEqual(n.error, "Connection timed out")
self.assertEqual(self.channel.last_error, "Connection timed out")
@patch("hc.api.transports.requests.request", side_effect=ConnectionError)
def test_webhooks_handle_connection_errors(self, mock_get):
self._setup_data("webhook", "http://example")
definition = {
"method_down": "GET",
"url_down": "http://example",
"body_down": "",
"headers_down": {},
}
self._setup_data("webhook", json.dumps(definition))
self.channel.notify(self.check)
n = Notification.objects.get()
self.assertEqual(n.error, "Connection failed")
@patch("hc.api.transports.requests.request")
def test_webhooks_ignore_up_events(self, mock_get):
self._setup_data("webhook", "http://example", status="up")
self.channel.notify(self.check)
self.assertFalse(mock_get.called)
self.assertEqual(Notification.objects.count(), 0)
@patch("hc.api.transports.requests.request")
def test_webhooks_handle_500(self, mock_get):
self._setup_data("webhook", "http://example")
definition = {
"method_down": "GET",
"url_down": "http://example",
"body_down": "",
"headers_down": {},
}
self._setup_data("webhook", json.dumps(definition))
mock_get.return_value.status_code = 500
self.channel.notify(self.check)
@ -74,40 +94,58 @@ class NotifyTestCase(BaseTestCase):
self.assertEqual(n.error, "Received status code 500")
@patch("hc.api.transports.requests.request")
def test_webhooks_support_tags(self, mock_get):
template = "http://host/$TAGS"
self._setup_data("webhook", template)
def test_webhooks_support_variables(self, mock_get):
definition = {
"method_down": "GET",
"url_down": "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME",
"body_down": "",
"headers_down": {},
}
self._setup_data("webhook", json.dumps(definition))
self.check.name = "Hello World"
self.check.tags = "foo bar"
self.check.save()
self.channel.notify(self.check)
url = "http://host/%s/down/foo/bar/?name=Hello%%20World" % self.check.code
args, kwargs = mock_get.call_args
self.assertEqual(args[0], "get")
self.assertEqual(args[1], "http://host/foo%20bar")
self.assertEqual(args[1], url)
self.assertEqual(kwargs["headers"], {"User-Agent": "healthchecks.io"})
self.assertEqual(kwargs["timeout"], 5)
@patch("hc.api.transports.requests.request")
def test_webhooks_support_variables(self, mock_get):
template = "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME"
self._setup_data("webhook", template)
self.check.name = "Hello World"
def test_webhooks_handle_variable_variables(self, mock_get):
definition = {
"method_down": "GET",
"url_down": "http://host/$$NAMETAG1",
"body_down": "",
"headers_down": {},
}
self._setup_data("webhook", json.dumps(definition))
self.check.tags = "foo bar"
self.check.save()
self.channel.notify(self.check)
url = "http://host/%s/down/foo/bar/?name=Hello%%20World" % self.check.code
# $$NAMETAG1 should *not* get transformed to "foo"
args, kwargs = mock_get.call_args
self.assertEqual(args[0], "get")
self.assertEqual(args[1], url)
self.assertEqual(kwargs["headers"], {"User-Agent": "healthchecks.io"})
self.assertEqual(kwargs["timeout"], 5)
self.assertEqual(args[1], "http://host/$TAG1")
@patch("hc.api.transports.requests.request")
def test_webhooks_support_post(self, mock_request):
template = "http://example.com\n\nThe Time Is $NOW"
self._setup_data("webhook", template)
definition = {
"method_down": "POST",
"url_down": "http://example.com",
"body_down": "The Time Is $NOW",
"headers_down": {},
}
self._setup_data("webhook", json.dumps(definition))
self.check.save()
self.channel.notify(self.check)
@ -123,9 +161,14 @@ class NotifyTestCase(BaseTestCase):
def test_webhooks_dollarsign_escaping(self, mock_get):
# If name or tag contains what looks like a variable reference,
# that should be left alone:
definition = {
"method_down": "GET",
"url_down": "http://host/$NAME",
"body_down": "",
"headers_down": {},
}
template = "http://host/$NAME"
self._setup_data("webhook", template)
self._setup_data("webhook", json.dumps(definition))
self.check.name = "$TAG1"
self.check.tags = "foo"
self.check.save()
@ -138,8 +181,14 @@ class NotifyTestCase(BaseTestCase):
)
@patch("hc.api.transports.requests.request")
def test_webhook_fires_on_up_event(self, mock_get):
self._setup_data("webhook", "http://foo\nhttp://bar", status="up")
def test_webhooks_handle_up_events(self, mock_get):
definition = {
"method_up": "GET",
"url_up": "http://bar",
"body_up": "",
"headers_up": {},
}
self._setup_data("webhook", json.dumps(definition), status="up")
self.channel.notify(self.check)
@ -148,47 +197,37 @@ class NotifyTestCase(BaseTestCase):
)
@patch("hc.api.transports.requests.request")
def test_webhooks_handle_unicode_post_body(self, mock_request):
template = "http://example.com\n\n(╯°□°)╯︵ ┻━┻"
self._setup_data("webhook", template)
self.check.save()
def test_webhooks_handle_noop_up_events(self, mock_get):
definition = {
"method_up": "GET",
"url_up": "",
"body_up": "",
"headers_up": {},
}
self._setup_data("webhook", json.dumps(definition), status="up")
self.channel.notify(self.check)
args, kwargs = mock_request.call_args
# unicode should be encoded into utf-8
self.assertIsInstance(kwargs["data"], bytes)
self.assertFalse(mock_get.called)
self.assertEqual(Notification.objects.count(), 0)
@patch("hc.api.transports.requests.request")
def test_webhooks_handle_json_value(self, mock_request):
def test_webhooks_handle_unicode_post_body(self, mock_request):
definition = {
"method_down": "GET",
"method_down": "POST",
"url_down": "http://foo.com",
"body_down": "",
"body_down": "(╯°□°)╯︵ ┻━┻",
"headers_down": {},
}
self._setup_data("webhook", json.dumps(definition))
self.channel.notify(self.check)
headers = {"User-Agent": "healthchecks.io"}
mock_request.assert_called_with(
"get", "http://foo.com", headers=headers, timeout=5
)
@patch("hc.api.transports.requests.request")
def test_webhooks_handle_json_up_event(self, mock_request):
definition = {
"method_up": "GET",
"url_up": "http://bar",
"body_up": "",
"headers_up": {},
}
self._setup_data("webhook", json.dumps(definition))
self.check.save()
self._setup_data("webhook", json.dumps(definition), status="up")
self.channel.notify(self.check)
args, kwargs = mock_request.call_args
headers = {"User-Agent": "healthchecks.io"}
mock_request.assert_called_with("get", "http://bar", headers=headers, timeout=5)
# unicode should be encoded into utf-8
self.assertIsInstance(kwargs["data"], bytes)
@patch("hc.api.transports.requests.request")
def test_webhooks_handle_post_headers(self, mock_request):
@ -275,6 +314,7 @@ class NotifyTestCase(BaseTestCase):
self.assertEqual(email.to[0], "[email protected]")
self.assertTrue("X-Bounce-Url" in email.extra_headers)
self.assertTrue("List-Unsubscribe" in email.extra_headers)
self.assertTrue("List-Unsubscribe-Post" in email.extra_headers)
def test_email_transport_handles_json_value(self):
payload = {"value": "[email protected]", "up": True, "down": True}
@ -305,6 +345,14 @@ class NotifyTestCase(BaseTestCase):
self.assertEqual(Notification.objects.count(), 0)
self.assertEqual(len(mail.outbox), 0)
def test_email_handles_amperstand(self):
self._setup_data("email", "[email protected]")
self.check.name = "Foo & Bar"
self.channel.notify(self.check)
email = mail.outbox[0]
self.assertEqual(email.subject, "DOWN | Foo & Bar")
@patch("hc.api.transports.requests.request")
def test_pd(self, mock_post):
self._setup_data("pd", "123")
@ -421,7 +469,7 @@ class NotifyTestCase(BaseTestCase):
self.assertEqual(Notification.objects.count(), 0)
@patch("hc.api.transports.requests.request")
def test_opsgenie(self, mock_post):
def test_opsgenie_with_legacy_value(self, mock_post):
self._setup_data("opsgenie", "123")
mock_post.return_value.status_code = 202
@ -431,6 +479,7 @@ class NotifyTestCase(BaseTestCase):
self.assertEqual(mock_post.call_count, 1)
args, kwargs = mock_post.call_args
self.assertIn("api.opsgenie.com", args[1])
payload = kwargs["json"]
self.assertIn("DOWN", payload["message"])
@ -448,6 +497,29 @@ class NotifyTestCase(BaseTestCase):
method, url = args
self.assertTrue(str(self.check.code) in url)
@patch("hc.api.transports.requests.request")
def test_opsgenie_with_json_value(self, mock_post):
self._setup_data("opsgenie", json.dumps({"key": "456", "region": "eu"}))
mock_post.return_value.status_code = 202
self.channel.notify(self.check)
n = Notification.objects.first()
self.assertEqual(n.error, "")
self.assertEqual(mock_post.call_count, 1)
args, kwargs = mock_post.call_args
self.assertIn("api.eu.opsgenie.com", args[1])
@patch("hc.api.transports.requests.request")
def test_opsgenie_returns_error(self, mock_post):
self._setup_data("opsgenie", "123")
mock_post.return_value.status_code = 403
mock_post.return_value.json.return_value = {"message": "Nice try"}
self.channel.notify(self.check)
n = Notification.objects.first()
self.assertEqual(n.error, 'Received status code 403 with a message: "Nice try"')
@patch("hc.api.transports.requests.request")
def test_pushover(self, mock_post):
self._setup_data("po", "123|0")
@ -528,6 +600,25 @@ class NotifyTestCase(BaseTestCase):
self.assertEqual(payload["chat_id"], 123)
self.assertTrue("The check" in payload["text"])
@patch("hc.api.transports.requests.request")
def test_telegram_returns_error(self, mock_post):
self._setup_data("telegram", json.dumps({"id": 123}))
mock_post.return_value.status_code = 400
mock_post.return_value.json.return_value = {"description": "Hi"}
self.channel.notify(self.check)
n = Notification.objects.first()
self.assertEqual(n.error, 'Received status code 400 with a message: "Hi"')
def test_telegram_obeys_rate_limit(self):
self._setup_data("telegram", json.dumps({"id": 123}))
TokenBucket.objects.create(value="tg-123", tokens=0)
self.channel.notify(self.check)
n = Notification.objects.first()
self.assertEqual(n.error, "Rate limit exceeded")
@patch("hc.api.transports.requests.request")
def test_sms(self, mock_post):
self._setup_data("sms", "+1234567890")
@ -577,6 +668,13 @@ class NotifyTestCase(BaseTestCase):
n = Notification.objects.get()
self.assertTrue("Monthly SMS limit exceeded" in n.error)
# And email should have been sent
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertEqual(email.to[0], "[email protected]")
self.assertEqual(email.subject, "Monthly SMS Limit Reached")
@patch("hc.api.transports.requests.request")
def test_sms_limit_reset(self, mock_post):
# At limit, but also into a new month
@ -638,6 +736,13 @@ class NotifyTestCase(BaseTestCase):
n = Notification.objects.get()
self.assertTrue("Monthly message limit exceeded" in n.error)
# And email should have been sent
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertEqual(email.to[0], "[email protected]")
self.assertEqual(email.subject, "Monthly WhatsApp Limit Reached")
@patch("apprise.Apprise")
@override_settings(APPRISE_ENABLED=True)
def test_apprise_enabled(self, mock_apprise):
@ -671,3 +776,97 @@ class NotifyTestCase(BaseTestCase):
with self.assertRaises(NotImplementedError):
self.channel.notify(self.check)
@patch("hc.api.transports.requests.request")
def test_msteams(self, mock_post):
self._setup_data("msteams", "http://example.com/webhook")
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
assert Notification.objects.count() == 1
args, kwargs = mock_post.call_args
payload = kwargs["json"]
self.assertEqual(payload["@type"], "MessageCard")
@patch("hc.api.transports.os.system")
@override_settings(SHELL_ENABLED=True)
def test_shell(self, mock_system):
definition = {"cmd_down": "logger hello", "cmd_up": ""}
self._setup_data("shell", json.dumps(definition))
mock_system.return_value = 0
self.channel.notify(self.check)
mock_system.assert_called_with("logger hello")
@patch("hc.api.transports.os.system")
@override_settings(SHELL_ENABLED=True)
def test_shell_handles_nonzero_exit_code(self, mock_system):
definition = {"cmd_down": "logger hello", "cmd_up": ""}
self._setup_data("shell", json.dumps(definition))
mock_system.return_value = 123
self.channel.notify(self.check)
n = Notification.objects.get()
self.assertEqual(n.error, "Command returned exit code 123")
@patch("hc.api.transports.os.system")
@override_settings(SHELL_ENABLED=True)
def test_shell_supports_variables(self, mock_system):
definition = {"cmd_down": "logger $NAME is $STATUS ($TAG1)", "cmd_up": ""}
self._setup_data("shell", json.dumps(definition))
mock_system.return_value = 0
self.check.name = "Database"
self.check.tags = "foo bar"
self.check.save()
self.channel.notify(self.check)
mock_system.assert_called_with("logger Database is down (foo)")
@patch("hc.api.transports.os.system")
@override_settings(SHELL_ENABLED=False)
def test_shell_disabled(self, mock_system):
definition = {"cmd_down": "logger hello", "cmd_up": ""}
self._setup_data("shell", json.dumps(definition))
self.channel.notify(self.check)
self.assertFalse(mock_system.called)
n = Notification.objects.get()
self.assertEqual(n.error, "Shell commands are not enabled")
@patch("hc.api.transports.requests.request")
def test_zulip(self, mock_post):
definition = {
"bot_email": "[email protected]",
"api_key": "fake-key",
"mtype": "stream",
"to": "general",
}
self._setup_data("zulip", json.dumps(definition))
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
assert Notification.objects.count() == 1
args, kwargs = mock_post.call_args
payload = kwargs["data"]
self.assertIn("DOWN", payload["topic"])
@patch("hc.api.transports.requests.request")
def test_zulip_returns_error(self, mock_post):
definition = {
"bot_email": "[email protected]",
"api_key": "fake-key",
"mtype": "stream",
"to": "general",
}
self._setup_data("zulip", json.dumps(definition))
mock_post.return_value.status_code = 403
mock_post.return_value.json.return_value = {"msg": "Nice try"}
self.channel.notify(self.check)
n = Notification.objects.first()
self.assertEqual(n.error, 'Received status code 403 with a message: "Nice try"')

+ 46
- 23
hc/api/tests/test_ping.py View File

@ -1,6 +1,7 @@
from datetime import timedelta as td
from django.test import Client
from django.test.utils import override_settings
from django.utils.timezone import now
from hc.api.models import Check, Flip, Ping
from hc.test import BaseTestCase
@ -10,9 +11,10 @@ class PingTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.check = Check.objects.create(project=self.project)
self.url = "/ping/%s/" % self.check.code
def test_it_works(self):
r = self.client.get("/ping/%s/" % self.check.code)
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
@ -23,12 +25,13 @@ class PingTestCase(BaseTestCase):
ping = Ping.objects.latest("id")
self.assertEqual(ping.scheme, "http")
self.assertEqual(ping.kind, None)
self.assertEqual(ping.created, self.check.last_ping)
def test_it_changes_status_of_paused_check(self):
self.check.status = "paused"
self.check.save()
r = self.client.get("/ping/%s/" % self.check.code)
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
@ -38,7 +41,7 @@ class PingTestCase(BaseTestCase):
self.check.last_start = now()
self.check.save()
r = self.client.get("/ping/%s/" % self.check.code)
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
@ -46,9 +49,7 @@ class PingTestCase(BaseTestCase):
def test_post_works(self):
csrf_client = Client(enforce_csrf_checks=True)
r = csrf_client.post(
"/ping/%s/" % self.check.code, "hello world", content_type="text/plain"
)
r = csrf_client.post(self.url, "hello world", content_type="text/plain")
self.assertEqual(r.status_code, 200)
ping = Ping.objects.latest("id")
@ -57,7 +58,7 @@ class PingTestCase(BaseTestCase):
def test_head_works(self):
csrf_client = Client(enforce_csrf_checks=True)
r = csrf_client.head("/ping/%s/" % self.check.code)
r = csrf_client.head(self.url)
self.assertEqual(r.status_code, 200)
self.assertEqual(Ping.objects.count(), 1)
@ -81,7 +82,7 @@ class PingTestCase(BaseTestCase):
"Chrome/44.0.2403.89 Safari/537.36"
)
r = self.client.get("/ping/%s/" % self.check.code, HTTP_USER_AGENT=ua)
r = self.client.get(self.url, HTTP_USER_AGENT=ua)
self.assertEqual(r.status_code, 200)
ping = Ping.objects.latest("id")
@ -90,7 +91,7 @@ class PingTestCase(BaseTestCase):
def test_it_truncates_long_ua(self):
ua = "01234567890" * 30
r = self.client.get("/ping/%s/" % self.check.code, HTTP_USER_AGENT=ua)
r = self.client.get(self.url, HTTP_USER_AGENT=ua)
self.assertEqual(r.status_code, 200)
ping = Ping.objects.latest("id")
@ -99,38 +100,30 @@ class PingTestCase(BaseTestCase):
def test_it_reads_forwarded_ip(self):
ip = "1.1.1.1"
r = self.client.get("/ping/%s/" % self.check.code, HTTP_X_FORWARDED_FOR=ip)
r = self.client.get(self.url, HTTP_X_FORWARDED_FOR=ip)
ping = Ping.objects.latest("id")
self.assertEqual(r.status_code, 200)
self.assertEqual(ping.remote_addr, "1.1.1.1")
ip = "1.1.1.1, 2.2.2.2"
r = self.client.get(
"/ping/%s/" % self.check.code,
HTTP_X_FORWARDED_FOR=ip,
REMOTE_ADDR="3.3.3.3",
)
r = self.client.get(self.url, HTTP_X_FORWARDED_FOR=ip, REMOTE_ADDR="3.3.3.3",)
ping = Ping.objects.latest("id")
self.assertEqual(r.status_code, 200)
self.assertEqual(ping.remote_addr, "1.1.1.1")
def test_it_reads_forwarded_protocol(self):
r = self.client.get(
"/ping/%s/" % self.check.code, HTTP_X_FORWARDED_PROTO="https"
)
r = self.client.get(self.url, HTTP_X_FORWARDED_PROTO="https")
ping = Ping.objects.latest("id")
self.assertEqual(r.status_code, 200)
self.assertEqual(ping.scheme, "https")
def test_it_never_caches(self):
r = self.client.get("/ping/%s/" % self.check.code)
r = self.client.get(self.url)
assert "no-cache" in r.get("Cache-Control")
def test_it_updates_confirmation_flag(self):
payload = "Please Confirm ..."
r = self.client.post(
"/ping/%s/" % self.check.code, data=payload, content_type="text/plain"
)
r = self.client.post(self.url, data=payload, content_type="text/plain")
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
@ -181,8 +174,38 @@ class PingTestCase(BaseTestCase):
self.check.last_start = now() - td(seconds=10)
self.check.save()
r = self.client.get("/ping/%s/" % self.check.code)
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
self.assertTrue(self.check.last_duration.total_seconds() >= 10)
def test_it_requires_post(self):
self.check.methods = "POST"
self.check.save()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
self.assertEqual(self.check.status, "new")
self.assertIsNone(self.check.last_ping)
ping = Ping.objects.latest("id")
self.assertEqual(ping.scheme, "http")
self.assertEqual(ping.kind, "ign")
@override_settings(PING_BODY_LIMIT=5)
def test_it_chops_long_body(self):
self.client.post(self.url, "hello world", content_type="text/plain")
ping = Ping.objects.latest("id")
self.assertEqual(ping.method, "POST")
self.assertEqual(ping.body, "hello")
@override_settings(PING_BODY_LIMIT=None)
def test_it_allows_unlimited_body(self):
self.client.post(self.url, "A" * 20000, content_type="text/plain")
ping = Ping.objects.latest("id")
self.assertEqual(len(ping.body), 20000)

+ 2
- 1
hc/api/tests/test_prunepingsslow.py View File

@ -1,4 +1,5 @@
from datetime import timedelta
from unittest.mock import Mock
from django.utils import timezone
from hc.api.management.commands.prunepingsslow import Command
@ -19,6 +20,6 @@ class PrunePingsSlowTestCase(BaseTestCase):
Ping.objects.create(owner=c, n=1)
Ping.objects.create(owner=c, n=2)
Command().handle()
Command(stdout=Mock()).handle()
self.assertEqual(Ping.objects.count(), 1)

+ 1
- 1
hc/api/tests/test_sendalerts.py View File

@ -1,6 +1,6 @@
from datetime import timedelta as td
from io import StringIO
from mock import Mock, patch
from unittest.mock import Mock, patch
from django.core.management import call_command
from django.utils.timezone import now


+ 29
- 13
hc/api/tests/test_sendreports.py View File

@ -1,35 +1,38 @@
from datetime import timedelta as td
from unittest.mock import Mock
from django.core import mail
from django.utils.timezone import now
from hc.api.management.commands.sendreports import Command
from hc.api.models import Check
from hc.test import BaseTestCase
from mock import Mock
class SendAlertsTestCase(BaseTestCase):
class SendReportsTestCase(BaseTestCase):
def setUp(self):
super(SendAlertsTestCase, self).setUp()
super(SendReportsTestCase, self).setUp()
# Make alice eligible for reports:
# account needs to be more than one month old
self.alice.date_joined = now() - td(days=365)
self.alice.save()
# Make alice eligible for nags:
# Make alice eligible for a monthly report:
self.profile.next_report_date = now() - td(hours=1)
# and for a nag
self.profile.nag_period = td(hours=1)
self.profile.next_nag_date = now() - td(seconds=10)
self.profile.save()
# Disable bob's and charlie's monthly reports so they don't interfere
self.bobs_profile.reports_allowed = False
self.bobs_profile.save()
self.charlies_profile.reports_allowed = False
self.charlies_profile.save()
# And it needs at least one check that has been pinged.
self.check = Check(project=self.project, last_ping=now())
self.check.status = "down"
self.check.save()
def test_it_sends_report(self):
cmd = Command()
cmd.stdout = Mock() # silence output to stdout
cmd = Command(stdout=Mock())
cmd.pause = Mock() # don't pause for 1s
found = cmd.handle_one_monthly_report()
@ -37,10 +40,12 @@ class SendAlertsTestCase(BaseTestCase):
self.profile.refresh_from_db()
self.assertTrue(self.profile.next_report_date > now())
self.assertEqual(self.profile.next_report_date.day, 1)
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertTrue("List-Unsubscribe" in email.extra_headers)
self.assertTrue("List-Unsubscribe-Post" in email.extra_headers)
def test_it_obeys_next_report_date(self):
self.profile.next_report_date = now() + td(days=1)
@ -49,6 +54,18 @@ class SendAlertsTestCase(BaseTestCase):
found = Command().handle_one_monthly_report()
self.assertFalse(found)
def test_it_fills_blank_next_report_date(self):
self.profile.next_report_date = None
self.profile.save()
found = Command().handle_one_monthly_report()
self.assertTrue(found)
self.profile.refresh_from_db()
self.assertTrue(self.profile.next_report_date)
self.assertEqual(self.profile.next_report_date.day, 1)
self.assertEqual(len(mail.outbox), 0)
def test_it_obeys_reports_allowed_flag(self):
self.profile.reports_allowed = False
self.profile.save()
@ -66,8 +83,7 @@ class SendAlertsTestCase(BaseTestCase):
self.assertEqual(len(mail.outbox), 0)
def test_it_sends_nag(self):
cmd = Command()
cmd.stdout = Mock() # silence output to stdout
cmd = Command(stdout=Mock())
cmd.pause = Mock() # don't pause for 1s
found = cmd.handle_one_nag()


+ 72
- 9
hc/api/tests/test_update_check.py View File

@ -1,5 +1,7 @@
from datetime import timedelta as td
import uuid
from django.utils.timezone import now
from hc.api.models import Channel, Check
from hc.test import BaseTestCase
@ -14,12 +16,17 @@ class UpdateCheckTestCase(BaseTestCase):
return self.client.post(url, data, content_type="application/json")
def test_it_works(self):
self.check.last_ping = now()
self.check.status = "up"
self.check.save()
r = self.post(
self.check.code,
{
"api_key": "X" * 32,
"name": "Foo",
"tags": "bar,baz",
"desc": "My description",
"timeout": 3600,
"grace": 60,
},
@ -32,7 +39,7 @@ class UpdateCheckTestCase(BaseTestCase):
assert "ping_url" in doc
self.assertEqual(doc["name"], "Foo")
self.assertEqual(doc["tags"], "bar,baz")
self.assertEqual(doc["last_ping"], None)
self.assertEqual(doc["desc"], "My description")
self.assertEqual(doc["n_pings"], 0)
self.assertTrue("schedule" not in doc)
@ -46,6 +53,10 @@ class UpdateCheckTestCase(BaseTestCase):
self.assertEqual(self.check.timeout.total_seconds(), 3600)
self.assertEqual(self.check.grace.total_seconds(), 60)
# alert_after should be updated too
expected_aa = self.check.last_ping + td(seconds=3600 + 60)
self.assertEqual(self.check.alert_after, expected_aa)
def test_it_handles_options(self):
r = self.client.options("/api/v1/checks/%s" % self.check.code)
self.assertEqual(r.status_code, 204)
@ -61,11 +72,6 @@ class UpdateCheckTestCase(BaseTestCase):
check = Check.objects.get()
self.assertEqual(check.channel_set.count(), 0)
def test_it_requires_post(self):
url = "/api/v1/checks/%s" % self.check.code
r = self.client.get(url, HTTP_X_API_KEY="X" * 32)
self.assertEqual(r.status_code, 405)
def test_it_handles_invalid_uuid(self):
r = self.post("not-an-uuid", {"api_key": "X" * 32})
self.assertEqual(r.status_code, 404)
@ -108,6 +114,15 @@ class UpdateCheckTestCase(BaseTestCase):
self.assertEqual(self.check.channel_set.count(), 1)
self.assertEqual(self.check.channel_set.first().code, channel.code)
def test_it_sets_the_channel_only_once(self):
channel = Channel.objects.create(project=self.project)
duplicates = "%s,%s" % (channel.code, channel.code)
r = self.post(self.check.code, {"api_key": "X" * 32, "channels": duplicates})
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
self.assertEqual(self.check.channel_set.count(), 1)
def test_it_handles_comma_separated_channel_codes(self):
c1 = Channel.objects.create(project=self.project)
c2 = Channel.objects.create(project=self.project)
@ -141,11 +156,33 @@ class UpdateCheckTestCase(BaseTestCase):
self.assertEqual(check.channel_set.count(), 1)
def test_it_rejects_bad_channel_code(self):
r = self.post(
self.check.code, {"api_key": "X" * 32, "channels": str(uuid.uuid4())}
)
payload = {"api_key": "X" * 32, "channels": "abc", "name": "New Name"}
r = self.post(self.check.code, payload,)
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json()["error"], "invalid channel identifier: abc")
# The name should be unchanged
self.check.refresh_from_db()
self.assertEqual(self.check.name, "")
def test_it_rejects_missing_channel(self):
code = str(uuid.uuid4())
r = self.post(self.check.code, {"api_key": "X" * 32, "channels": code})
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json()["error"], "invalid channel identifier: " + code)
self.check.refresh_from_db()
self.assertEqual(self.check.channel_set.count(), 0)
def test_it_rejects_channel_from_another_project(self):
charlies_channel = Channel.objects.create(project=self.charlies_project)
code = str(charlies_channel.code)
r = self.post(self.check.code, {"api_key": "X" * 32, "channels": code})
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json()["error"], "invalid channel identifier: " + code)
self.check.refresh_from_db()
self.assertEqual(self.check.channel_set.count(), 0)
@ -162,3 +199,29 @@ class UpdateCheckTestCase(BaseTestCase):
r = self.post(self.check.code, {"api_key": "X" * 32, "channels": None})
self.assertEqual(r.status_code, 400)
def test_it_rejects_non_string_desc(self):
r = self.post(self.check.code, {"api_key": "X" * 32, "desc": 123})
self.assertEqual(r.status_code, 400)
def test_it_validates_cron_expression(self):
self.check.kind = "cron"
self.check.schedule = "5 * * * *"
self.check.save()
samples = ["* invalid *", "1,2 3,* * * *", "0 0 31 2 *"]
for sample in samples:
r = self.post(self.check.code, {"api_key": "X" * 32, "schedule": sample})
self.assertEqual(r.status_code, 400, "Did not reject '%s'" % sample)
# Schedule should be unchanged
self.check.refresh_from_db()
self.assertEqual(self.check.schedule, "5 * * * *")
def test_it_rejects_readonly_key(self):
self.project.api_key_readonly = "R" * 32
self.project.save()
r = self.post(self.check.code, {"api_key": "R" * 32, "name": "Foo"})
self.assertEqual(r.status_code, 401)

+ 144
- 38
hc/api/transports.py View File

@ -1,3 +1,5 @@
import os
from django.conf import settings
from django.template.loader import render_to_string
from django.utils import timezone
@ -7,6 +9,7 @@ from urllib.parse import quote, urlencode
from hc.accounts.models import Profile
from hc.lib import emails
from hc.lib.string import replace
try:
import apprise
@ -58,7 +61,11 @@ class Email(Transport):
unsub_link = self.channel.get_unsub_link()
headers = {"X-Bounce-Url": bounce_url, "List-Unsubscribe": unsub_link}
headers = {
"X-Bounce-Url": bounce_url,
"List-Unsubscribe": "<%s>" % unsub_link,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
try:
# Look up the sorting preference for this email address
@ -90,7 +97,55 @@ class Email(Transport):
return not self.channel.email_notify_up
class Shell(Transport):
def prepare(self, template, check):
""" Replace placeholders with actual values. """
ctx = {
"$CODE": str(check.code),
"$STATUS": check.status,
"$NOW": timezone.now().replace(microsecond=0).isoformat(),
"$NAME": check.name,
"$TAGS": check.tags,
}
for i, tag in enumerate(check.tags_list()):
ctx["$TAG%d" % (i + 1)] = tag
return replace(template, ctx)
def is_noop(self, check):
if check.status == "down" and not self.channel.cmd_down:
return True
if check.status == "up" and not self.channel.cmd_up:
return True
return False
def notify(self, check):
if not settings.SHELL_ENABLED:
return "Shell commands are not enabled"
if check.status == "up":
cmd = self.channel.cmd_up
elif check.status == "down":
cmd = self.channel.cmd_down
cmd = self.prepare(cmd, check)
code = os.system(cmd)
if code != 0:
return "Command returned exit code %d" % code
class HttpTransport(Transport):
@classmethod
def get_error(cls, response):
# Override in subclasses: look for a specific error message in the
# response and return it.
return None
@classmethod
def _request(cls, method, url, **kwargs):
try:
@ -103,7 +158,12 @@ class HttpTransport(Transport):
r = requests.request(method, url, **options)
if r.status_code not in (200, 201, 202, 204):
return "Received status code %d" % r.status_code
m = cls.get_error(r)
if m:
return f'Received status code {r.status_code} with a message: "{m}"'
return f"Received status code {r.status_code}"
except requests.exceptions.Timeout:
# Well, we tried
return "Connection timed out"
@ -143,39 +203,23 @@ class HttpTransport(Transport):
class Webhook(HttpTransport):
def prepare(self, template, check, urlencode=False):
""" Replace variables with actual values.
There should be no bad translations if users use $ symbol in
check's name or tags, because $ gets urlencoded to %24
"""
""" Replace variables with actual values. """
def safe(s):
return quote(s) if urlencode else s
result = template
if "$CODE" in result:
result = result.replace("$CODE", str(check.code))
if "$STATUS" in result:
result = result.replace("$STATUS", check.status)
if "$NOW" in result:
s = timezone.now().replace(microsecond=0).isoformat()
result = result.replace("$NOW", safe(s))
if "$NAME" in result:
result = result.replace("$NAME", safe(check.name))
if "$TAGS" in result:
result = result.replace("$TAGS", safe(check.tags))
ctx = {
"$CODE": str(check.code),
"$STATUS": check.status,
"$NOW": safe(timezone.now().replace(microsecond=0).isoformat()),
"$NAME": safe(check.name),
"$TAGS": safe(check.tags),
}
if "$TAG" in result:
for i, tag in enumerate(check.tags_list()):
placeholder = "$TAG%d" % (i + 1)
result = result.replace(placeholder, safe(tag))
for i, tag in enumerate(check.tags_list()):
ctx["$TAG%d" % (i + 1)] = safe(tag)
return result
return replace(template, ctx)
def is_noop(self, check):
if check.status == "down" and not self.channel.url_down:
@ -188,7 +232,8 @@ class Webhook(HttpTransport):
def notify(self, check):
spec = self.channel.webhook_spec(check.status)
assert spec["url"]
if not spec["url"]:
return "Empty webhook URL"
url = self.prepare(spec["url"], check, urlencode=True)
headers = {}
@ -220,10 +265,17 @@ class HipChat(HttpTransport):
class OpsGenie(HttpTransport):
@classmethod
def get_error(cls, response):
try:
return response.json().get("message")
except ValueError:
pass
def notify(self, check):
headers = {
"Conent-Type": "application/json",
"Authorization": "GenieKey %s" % self.channel.value,
"Authorization": "GenieKey %s" % self.channel.opsgenie_key,
}
payload = {"alias": str(check.code), "source": settings.SITE_NAME}
@ -235,6 +287,9 @@ class OpsGenie(HttpTransport):
payload["description"] = tmpl("opsgenie_description.html", check=check)
url = "https://api.opsgenie.com/v2/alerts"
if self.channel.opsgenie_region == "eu":
url = "https://api.eu.opsgenie.com/v2/alerts"
if check.status == "up":
url += "/%s/close?identifierType=alias" % check.code
@ -247,13 +302,12 @@ class PagerDuty(HttpTransport):
def notify(self, check):
description = tmpl("pd_description.html", check=check)
payload = {
"vendor": settings.PD_VENDOR_KEY,
"service_key": self.channel.pd_service_key,
"incident_key": str(check.code),
"event_type": "trigger" if check.status == "down" else "resolve",
"description": description,
"client": settings.SITE_NAME,
"client_url": settings.SITE_ROOT,
"client_url": check.details_url(),
}
return self.post(self.URL, json=payload)
@ -389,13 +443,27 @@ class Discord(HttpTransport):
class Telegram(HttpTransport):
SM = "https://api.telegram.org/bot%s/sendMessage" % settings.TELEGRAM_TOKEN
@classmethod
def get_error(cls, response):
try:
return response.json().get("description")
except ValueError:
pass
@classmethod
def send(cls, chat_id, text):
# Telegram.send is a separate method because it is also used in
# hc.front.views.telegram_bot to send invite links.
return cls.post(
cls.SM, json={"chat_id": chat_id, "text": text, "parse_mode": "html"}
)
def notify(self, check):
from hc.api.models import TokenBucket
if not TokenBucket.authorize_telegram(self.channel.telegram_id):
return "Rate limit exceeded"
text = tmpl("telegram_message.html", check=check)
return self.send(self.channel.telegram_id, text)
@ -409,6 +477,7 @@ class Sms(HttpTransport):
def notify(self, check):
profile = Profile.objects.for_user(self.channel.project.owner)
if not profile.authorize_sms():
profile.send_sms_limit_notice("SMS")
return "Monthly SMS limit exceeded"
url = self.URL % settings.TWILIO_ACCOUNT
@ -436,6 +505,7 @@ class WhatsApp(HttpTransport):
def notify(self, check):
profile = Profile.objects.for_user(self.channel.project.owner)
if not profile.authorize_sms():
profile.send_sms_limit_notice("WhatsApp")
return "Monthly message limit exceeded"
url = self.URL % settings.TWILIO_ACCOUNT
@ -468,12 +538,13 @@ class Trello(HttpTransport):
return self.post(self.URL, params=params)
class Apprise(HttpTransport):
def notify(self, check):
if not settings.APPRISE_ENABLED:
# Not supported and/or enabled
return "Apprise is disabled and/or not installed."
return "Apprise is disabled and/or not installed"
a = apprise.Apprise()
title = tmpl("apprise_title.html", check=check)
@ -481,8 +552,43 @@ class Apprise(HttpTransport):
a.add(self.channel.value)
notify_type = apprise.NotifyType.SUCCESS \
if check.status == "up" else apprise.NotifyType.FAILURE
notify_type = (
apprise.NotifyType.SUCCESS
if check.status == "up"
else apprise.NotifyType.FAILURE
)
return (
"Failed"
if not a.notify(body=body, title=title, notify_type=notify_type)
else None
)
class MsTeams(HttpTransport):
def notify(self, check):
text = tmpl("msteams_message.json", check=check)
payload = json.loads(text)
return self.post(self.channel.value, json=payload)
return "Failed" if not \
a.notify(body=body, title=title, notify_type=notify_type) else None
class Zulip(HttpTransport):
@classmethod
def get_error(cls, response):
try:
return response.json().get("msg")
except ValueError:
pass
def notify(self, check):
_, domain = self.channel.zulip_bot_email.split("@")
url = "https://%s/api/v1/messages" % domain
auth = (self.channel.zulip_bot_email, self.channel.zulip_api_key)
data = {
"type": self.channel.zulip_type,
"to": self.channel.zulip_to,
"topic": tmpl("zulip_topic.html", check=check),
"content": tmpl("zulip_content.html", check=check),
}
return self.post(url, data=data, auth=auth)

+ 3
- 15
hc/api/urls.py View File

@ -22,32 +22,20 @@ urlpatterns = [
path("ping/<uuid:code>/fail", views.ping, {"action": "fail"}, name="hc-fail"),
path("ping/<uuid:code>/start", views.ping, {"action": "start"}, name="hc-start"),
path("api/v1/checks/", views.checks),
path("api/v1/checks/<uuid:code>", views.update, name="hc-api-update"),
path("api/v1/checks/<uuid:code>", views.single, name="hc-api-single"),
path("api/v1/checks/<uuid:code>/pause", views.pause, name="hc-api-pause"),
path("api/v1/notifications/<uuid:code>/bounce", views.bounce, name="hc-api-bounce"),
path("api/v1/channels/", views.channels),
path(
"badge/<slug:badge_key>/<slug:signature>/<quoted:tag>.svg",
"badge/<slug:badge_key>/<slug:signature>/<quoted:tag>.<slug:fmt>",
views.badge,
name="hc-badge",
),
path(
"badge/<slug:badge_key>/<slug:signature>.svg",
"badge/<slug:badge_key>/<slug:signature>.<slug:fmt>",
views.badge,
{"tag": "*"},
name="hc-badge-all",
),
path(
"badge/<slug:badge_key>/<slug:signature>/<quoted:tag>.json",
views.badge,
{"format": "json"},
name="hc-badge-json",
),
path(
"badge/<slug:badge_key>/<slug:signature>.json",
views.badge,
{"format": "json", "tag": "*"},
name="hc-badge-json-all",
),
path("api/v1/status/", views.status),
]

+ 98
- 36
hc/api/views.py View File

@ -2,7 +2,6 @@ from datetime import timedelta as td
import uuid
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.db import connection
from django.http import (
HttpResponse,
@ -14,6 +13,7 @@ from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from hc.api import schemas
from hc.api.decorators import authorize, authorize_read, cors, validate_json
@ -21,6 +21,10 @@ from hc.api.models import Check, Notification, Channel
from hc.lib.badges import check_signature, get_badge_svg
class BadChannelException(Exception):
pass
@csrf_exempt
@never_cache
def ping(request, code, action="success"):
@ -34,6 +38,9 @@ def ping(request, code, action="success"):
ua = headers.get("HTTP_USER_AGENT", "")
body = request.body.decode()
if check.methods == "POST" and method != "POST":
action = "ign"
check.ping(remote_addr, scheme, method, ua, body, action)
response = HttpResponse("OK")
@ -60,12 +67,30 @@ def _lookup(project, spec):
def _update(check, spec):
channels = set()
# First, validate the supplied channel codes
if "channels" in spec and spec["channels"] not in ("*", ""):
q = Channel.objects.filter(project=check.project)
for s in spec["channels"].split(","):
try:
code = uuid.UUID(s)
except ValueError:
raise BadChannelException("invalid channel identifier: %s" % s)
try:
channels.add(q.get(code=code))
except Channel.DoesNotExist:
raise BadChannelException("invalid channel identifier: %s" % s)
if "name" in spec:
check.name = spec["name"]
if "tags" in spec:
check.tags = spec["tags"]
if "desc" in spec:
check.desc = spec["desc"]
if "timeout" in spec and "schedule" not in spec:
check.kind = "simple"
check.timeout = td(seconds=spec["timeout"])
@ -79,29 +104,17 @@ def _update(check, spec):
if "tz" in spec:
check.tz = spec["tz"]
check.alert_after = check.going_down_after()
check.save()
# This needs to be done after saving the check, because of
# the M2M relation between checks and channels:
if "channels" in spec:
if spec["channels"] == "*":
check.assign_all_channels()
elif spec["channels"] == "":
check.channel_set.clear()
else:
channels = []
for chunk in spec["channels"].split(","):
try:
chunk = uuid.UUID(chunk)
except ValueError:
raise SuspiciousOperation("Invalid channel identifier")
try:
channel = Channel.objects.get(code=chunk)
channels.append(channel)
except Channel.DoesNotExist:
raise SuspiciousOperation("Invalid channel identifier")
check.channel_set.set(channels)
if spec.get("channels") == "*":
check.assign_all_channels()
elif spec.get("channels") == "":
check.channel_set.clear()
elif channels:
check.channel_set.set(channels)
return check
@ -138,7 +151,11 @@ def create_check(request):
check = Check(project=request.project)
created = True
_update(check, request.json)
try:
_update(check, request.json)
except BadChannelException as e:
return JsonResponse({"error": str(e)}, status=400)
return JsonResponse(check.to_dict(), status=201 if created else 200)
@ -160,26 +177,53 @@ def channels(request):
return JsonResponse({"channels": channels})
@csrf_exempt
@cors("POST", "DELETE")
@validate_json()
@authorize_read
def get_check(request, code):
check = get_object_or_404(Check, code=code)
if check.project != request.project:
return HttpResponseForbidden()
return JsonResponse(check.to_dict(readonly=request.readonly))
@validate_json(schemas.check)
@authorize
def update(request, code):
def update_check(request, code):
check = get_object_or_404(Check, code=code)
if check.project != request.project:
return HttpResponseForbidden()
if request.method == "POST":
try:
_update(check, request.json)
return JsonResponse(check.to_dict())
except BadChannelException as e:
return JsonResponse({"error": str(e)}, status=400)
return JsonResponse(check.to_dict())
@validate_json()
@authorize
def delete_check(request, code):
check = get_object_or_404(Check, code=code)
if check.project != request.project:
return HttpResponseForbidden()
response = check.to_dict()
check.delete()
return JsonResponse(response)
elif request.method == "DELETE":
response = check.to_dict()
check.delete()
return JsonResponse(response)
# Otherwise, method not allowed
return HttpResponse(status=405)
@csrf_exempt
@cors("POST", "DELETE", "GET")
def single(request, code):
if request.method == "POST":
return update_check(request, code)
if request.method == "DELETE":
return delete_check(request, code)
return get_check(request, code)
@cors("POST")
@ -200,10 +244,13 @@ def pause(request, code):
@never_cache
@cors("GET")
def badge(request, badge_key, signature, tag, format="svg"):
def badge(request, badge_key, signature, tag, fmt="svg"):
if not check_signature(badge_key, tag, signature):
return HttpResponseNotFound()
if fmt not in ("svg", "json", "shields"):
return HttpResponseNotFound()
q = Check.objects.filter(project__badge_key=badge_key)
if tag != "*":
q = q.filter(tags__contains=tag)
@ -222,7 +269,7 @@ def badge(request, badge_key, signature, tag, format="svg"):
if check_status == "down":
down += 1
status = "down"
if format == "svg":
if fmt == "svg":
# For SVG badges, we can leave the loop as soon as we
# find the first "down"
break
@ -231,7 +278,16 @@ def badge(request, badge_key, signature, tag, format="svg"):
if status == "up":
status = "late"
if format == "json":
if fmt == "shields":
color = "success"
if status == "down":
color = "critical"
elif status == "late":
color = "important"
return JsonResponse({"label": label, "message": status, "color": color})
if fmt == "json":
return JsonResponse(
{"status": status, "total": total, "grace": grace, "down": down}
)
@ -241,6 +297,7 @@ def badge(request, badge_key, signature, tag, format="svg"):
@csrf_exempt
@require_POST
def bounce(request, code):
notification = get_object_or_404(Notification, code=code)
@ -252,7 +309,12 @@ def bounce(request, code):
notification.error = request.body.decode()[:200]
notification.save()
notification.channel.email_verified = False
notification.channel.last_error = notification.error
if request.GET.get("type") in (None, "Permanent"):
# For permanent bounces, mark the channel as not verified, so we
# will not try to deliver to it again.
notification.channel.email_verified = False
notification.channel.save()
return HttpResponse()


+ 1
- 3
hc/front/admin.py View File

@ -1,3 +1 @@
from django.contrib import admin
# Register your models here.
# The front app has no models.

+ 18
- 0
hc/front/decorators.py View File

@ -0,0 +1,18 @@
from functools import wraps
from django.conf import settings
from django.http import HttpResponse
def require_setting(key):
def decorator(f):
@wraps(f)
def wrapper(request, *args, **kwds):
if not getattr(settings, key):
return HttpResponse(status=404)
return f(request, *args, **kwds)
return wrapper
return decorator

+ 78
- 5
hc/front/forms.py View File

@ -27,7 +27,7 @@ class HeadersField(forms.Field):
if not line.strip():
continue
if ":" not in value:
if ":" not in line:
raise ValidationError(self.message)
n, v = line.split(":", maxsplit=1)
@ -62,8 +62,9 @@ class NameTagsForm(forms.Form):
return " ".join(result)
class EmailSettingsForm(forms.Form):
class FilteringRulesForm(forms.Form):
subject = forms.CharField(max_length=100)
methods = forms.ChoiceField(required=False, choices=(("", "Any"), ("POST", "POST")))
class TimeoutForm(forms.Form):
@ -85,7 +86,30 @@ class CronForm(forms.Form):
class AddOpsGenieForm(forms.Form):
error_css_class = "has-error"
value = forms.CharField(max_length=40)
region = forms.ChoiceField(initial="us", choices=(("us", "US"), ("eu", "EU")))
key = forms.CharField(max_length=40)
PRIO_CHOICES = [
("-2", "Lowest Priority"),
("-1", "Low Priority"),
("0", "Normal Priority"),
("1", "High Priority"),
("2", "Emergency Priority"),
]
class AddPushoverForm(forms.Form):
error_css_class = "has-error"
pushover_user_key = forms.CharField()
prio = forms.ChoiceField(initial="0", choices=PRIO_CHOICES)
prio_up = forms.ChoiceField(initial="0", choices=PRIO_CHOICES)
def get_value(self):
key = self.cleaned_data["pushover_user_key"]
prio = self.cleaned_data["prio"]
prio_up = self.cleaned_data["prio_up"]
return "%s|%s|%s" % (key, prio, prio_up)
class AddEmailForm(forms.Form):
@ -94,6 +118,15 @@ class AddEmailForm(forms.Form):
down = forms.BooleanField(required=False, initial=True)
up = forms.BooleanField(required=False, initial=True)
def clean(self):
super().clean()
down = self.cleaned_data.get("down")
up = self.cleaned_data.get("up")
if not down and not up:
self.add_error("down", "Please select at least one.")
class AddUrlForm(forms.Form):
error_css_class = "has-error"
@ -103,8 +136,9 @@ class AddUrlForm(forms.Form):
METHODS = ("GET", "POST", "PUT")
class AddWebhookForm(forms.Form):
class WebhookForm(forms.Form):
error_css_class = "has-error"
name = forms.CharField(max_length=100, required=False)
method_down = forms.ChoiceField(initial="GET", choices=zip(METHODS, METHODS))
body_down = forms.CharField(max_length=1000, required=False)
@ -120,6 +154,26 @@ class AddWebhookForm(forms.Form):
max_length=1000, required=False, validators=[WebhookValidator()]
)
def clean(self):
super().clean()
url_down = self.cleaned_data.get("url_down")
url_up = self.cleaned_data.get("url_up")
if not url_down and not url_up:
if not self.has_error("url_down"):
self.add_error("url_down", "Enter a valid URL.")
def get_value(self):
return json.dumps(dict(self.cleaned_data), sort_keys=True)
class AddShellForm(forms.Form):
error_css_class = "has-error"
cmd_down = forms.CharField(max_length=1000, required=False)
cmd_up = forms.CharField(max_length=1000, required=False)
def get_value(self):
return json.dumps(dict(self.cleaned_data), sort_keys=True)
@ -143,7 +197,7 @@ class ChannelNameForm(forms.Form):
class AddMatrixForm(forms.Form):
error_css_class = "has-error"
alias = forms.CharField(max_length=40)
alias = forms.CharField(max_length=100)
def clean_alias(self):
v = self.cleaned_data["alias"]
@ -164,3 +218,22 @@ class AddMatrixForm(forms.Form):
class AddAppriseForm(forms.Form):
error_css_class = "has-error"
url = forms.CharField(max_length=512)
class AddPdForm(forms.Form):
error_css_class = "has-error"
value = forms.CharField(max_length=32)
ZULIP_TARGETS = (("stream", "Stream"), ("private", "Private"))
class AddZulipForm(forms.Form):
error_css_class = "has-error"
bot_email = forms.EmailField(max_length=100)
api_key = forms.CharField(max_length=50)
mtype = forms.ChoiceField(choices=ZULIP_TARGETS)
to = forms.CharField(max_length=100)
def get_value(self):
return json.dumps(dict(self.cleaned_data), sort_keys=True)

+ 2
- 16
hc/front/management/commands/pygmentize.py View File

@ -22,7 +22,7 @@ class Command(BaseCommand):
try:
from pygments import lexers
except ImportError:
self.stdout.write("This command requires Pygments package.")
self.stdout.write("This command requires the Pygments package.")
self.stdout.write("Please install it with:\n\n")
self.stdout.write(" pip install Pygments\n\n")
return
@ -34,6 +34,7 @@ class Command(BaseCommand):
_process("crontab", lexers.BashLexer())
_process("cs", lexers.CSharpLexer())
_process("node", lexers.JavascriptLexer())
_process("go", lexers.GoLexer())
_process("python_urllib2", lexers.PythonLexer())
_process("python_requests", lexers.PythonLexer())
_process("python_requests_fail", lexers.PythonLexer())
@ -43,18 +44,3 @@ class Command(BaseCommand):
_process("powershell", lexers.shell.PowerShellLexer())
_process("powershell_inline", lexers.shell.BashLexer())
_process("ruby", lexers.RubyLexer())
# API examples
_process("list_checks_request", lexers.BashLexer())
_process("list_checks_response", lexers.JsonLexer())
_process("list_checks_response_readonly", lexers.JsonLexer())
_process("list_channels_request", lexers.BashLexer())
_process("list_channels_response", lexers.JsonLexer())
_process("create_check_request_a", lexers.BashLexer())
_process("create_check_request_b", lexers.BashLexer())
_process("update_check_request_a", lexers.BashLexer())
_process("update_check_request_b", lexers.BashLexer())
_process("create_check_response", lexers.JsonLexer())
_process("pause_check_request", lexers.BashLexer())
_process("pause_check_response", lexers.JsonLexer())
_process("delete_check_request", lexers.BashLexer())

+ 39
- 0
hc/front/management/commands/render_docs.py View File

@ -0,0 +1,39 @@
import os
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Renders Markdown to HTML"
def handle(self, *args, **options):
try:
import markdown
# We use pygments for highlighting code samples
import pygments
except ImportError as e:
self.stdout.write(f"This command requires the {e.name} package.")
self.stdout.write("Please install it with:\n\n")
self.stdout.write(f" pip install {e.name}\n\n")
return
extensions = ["fenced_code", "codehilite", "tables", "def_list", "attr_list"]
ec = {"codehilite": {"css_class": "highlight"}}
docs_path = os.path.join(settings.BASE_DIR, "templates/docs")
for doc in os.listdir(docs_path):
if not doc.endswith(".md"):
continue
print("Rendering %s" % doc)
src_path = os.path.join(docs_path, doc)
dst_path = os.path.join(docs_path, doc[:-3] + ".html")
text = open(src_path, "r", encoding="utf-8").read()
html = markdown.markdown(text, extensions=extensions, extension_configs=ec)
with open(dst_path, "w", encoding="utf-8") as f:
f.write(html)

+ 1
- 3
hc/front/models.py View File

@ -1,3 +1 @@
from django.db import models
# Create your models here.
# The front app has no models.

+ 24
- 0
hc/front/templatetags/hc_extras.py View File

@ -4,6 +4,7 @@ from django import template
from django.conf import settings
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from hc.lib.date import format_duration, format_approx_duration, format_hms
@ -40,6 +41,24 @@ def site_root():
return settings.SITE_ROOT
@register.simple_tag
def site_scheme():
parts = settings.SITE_ROOT.split("://")
assert parts[0] in ("http", "https")
return parts[0]
@register.simple_tag
def site_hostname():
parts = settings.SITE_ROOT.split("://")
return parts[1]
@register.simple_tag
def site_version():
return settings.VERSION
@register.simple_tag
def debug_warning():
if settings.DEBUG:
@ -133,3 +152,8 @@ def fix_asterisks(s):
@register.filter
def format_headers(headers):
return "\n".join("%s: %s" % (k, v) for k, v in headers.items())
@register.simple_tag
def now_isoformat():
return now().replace(microsecond=0).isoformat()

+ 8
- 4
hc/front/tests/test_add_apprise.py View File

@ -5,17 +5,21 @@ from django.test.utils import override_settings
@override_settings(APPRISE_ENABLED=True)
class AddAppriseTestCase(BaseTestCase):
def setUp(self):
super(AddAppriseTestCase, self).setUp()
self.url = "/projects/%s/add_apprise/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_apprise/")
r = self.client.get(self.url)
self.assertContains(r, "Integration Settings", status_code=200)
def test_it_works(self):
form = {"url": "json://example.org"}
self.client.login(username="[email protected]", password="password")
r = self.client.post("/integrations/add_apprise/", form)
self.assertRedirects(r, "/integrations/")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "apprise")
@ -25,5 +29,5 @@ class AddAppriseTestCase(BaseTestCase):
@override_settings(APPRISE_ENABLED=False)
def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_apprise/")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 0
- 13
hc/front/tests/test_add_check.py View File

@ -19,19 +19,6 @@ class AddCheckTestCase(BaseTestCase):
redirect_url = "/checks/%s/details/?new" % check.code
self.assertRedirects(r, redirect_url)
def test_it_handles_unset_current_project(self):
self.profile.current_project = None
self.profile.save()
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url)
check = Check.objects.get()
self.assertEqual(check.project, self.project)
redirect_url = "/checks/%s/details/?new" % check.code
self.assertRedirects(r, redirect_url)
def test_team_access_works(self):
self.client.login(username="[email protected]", password="password")
self.client.post(self.url)


+ 4
- 45
hc/front/tests/test_add_discord.py View File

@ -1,14 +1,12 @@
import json
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
from mock import patch
@override_settings(DISCORD_CLIENT_ID="t1", DISCORD_CLIENT_SECRET="s1")
class AddDiscordTestCase(BaseTestCase):
url = "/integrations/add_discord/"
def setUp(self):
super(AddDiscordTestCase, self).setUp()
self.url = "/projects/%s/add_discord/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -17,49 +15,10 @@ class AddDiscordTestCase(BaseTestCase):
self.assertContains(r, "discordapp.com/api/oauth2/authorize")
# There should now be a key in session
self.assertTrue("discord" in self.client.session)
self.assertTrue("add_discord" in self.client.session)
@override_settings(DISCORD_CLIENT_ID=None)
def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
@patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["discord"] = "foo"
session.save()
oauth_response = {
"access_token": "test-token",
"webhook": {"url": "foo", "id": "bar"},
}
mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response
url = self.url + "?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, "/integrations/")
self.assertContains(r, "The Discord integration has been added!")
ch = Channel.objects.get()
self.assertEqual(ch.discord_webhook_url, "foo")
self.assertEqual(ch.project, self.project)
# Session should now be clean
self.assertFalse("discord" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["discord"] = "foo"
session.save()
url = self.url + "?code=12345678&state=bar"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 400)

+ 76
- 0
hc/front/tests/test_add_discord_complete.py View File

@ -0,0 +1,76 @@
import json
from unittest.mock import patch
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
@override_settings(DISCORD_CLIENT_ID="t1", DISCORD_CLIENT_SECRET="s1")
class AddDiscordCompleteTestCase(BaseTestCase):
url = "/integrations/add_discord/"
@patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["add_discord"] = ("foo", str(self.project.code))
session.save()
oauth_response = {
"access_token": "test-token",
"webhook": {"url": "foo", "id": "bar"},
}
mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response
url = self.url + "?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, self.channels_url)
self.assertContains(r, "The Discord integration has been added!")
ch = Channel.objects.get()
self.assertEqual(ch.discord_webhook_url, "foo")
self.assertEqual(ch.project, self.project)
# Session should now be clean
self.assertFalse("add_discord" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["add_discord"] = ("foo", str(self.project.code))
session.save()
url = self.url + "?code=12345678&state=bar"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 403)
# Session should now be clean
self.assertFalse("add_discord" in self.client.session)
def test_it_handles_access_denied(self):
session = self.client.session
session["add_discord"] = ("foo", str(self.project.code))
session.save()
url = self.url + "?error=access_denied"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, self.channels_url)
self.assertContains(r, "Discord setup was cancelled.")
self.assertEqual(Channel.objects.count(), 0)
# Session should now be clean
self.assertFalse("add_discord" in self.client.session)
@override_settings(DISCORD_CLIENT_ID=None)
def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url + "?code=12345678&state=bar")
self.assertEqual(r.status_code, 404)

+ 19
- 10
hc/front/tests/test_add_email.py View File

@ -8,7 +8,9 @@ from hc.test import BaseTestCase
class AddEmailTestCase(BaseTestCase):
url = "/integrations/add_email/"
def setUp(self):
super(AddEmailTestCase, self).setUp()
self.url = "/projects/%s/add_email/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -17,11 +19,11 @@ class AddEmailTestCase(BaseTestCase):
self.assertContains(r, "Requires confirmation")
def test_it_creates_channel(self):
form = {"value": "[email protected]"}
form = {"value": "[email protected]", "down": "true", "up": "true"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
doc = json.loads(c.value)
@ -39,7 +41,7 @@ class AddEmailTestCase(BaseTestCase):
self.assertEqual(email.to[0], "[email protected]")
def test_team_access_works(self):
form = {"value": "[email protected]"}
form = {"value": "[email protected]", "down": "true", "up": "true"}
self.client.login(username="[email protected]", password="password")
self.client.post(self.url, form)
@ -49,14 +51,14 @@ class AddEmailTestCase(BaseTestCase):
self.assertEqual(ch.project, self.project)
def test_it_rejects_bad_email(self):
form = {"value": "not an email address"}
form = {"value": "not an email address", "down": "true", "up": "true"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertContains(r, "Enter a valid email address.")
def test_it_trims_whitespace(self):
form = {"value": " [email protected] "}
form = {"value": " [email protected] ", "down": "true", "up": "true"}
self.client.login(username="[email protected]", password="password")
self.client.post(self.url, form)
@ -73,11 +75,11 @@ class AddEmailTestCase(BaseTestCase):
@override_settings(EMAIL_USE_VERIFICATION=False)
def test_it_auto_verifies_email(self):
form = {"value": "[email protected]"}
form = {"value": "[email protected]", "down": "true", "up": "true"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
doc = json.loads(c.value)
@ -89,11 +91,11 @@ class AddEmailTestCase(BaseTestCase):
self.assertEqual(len(mail.outbox), 0)
def test_it_auto_verifies_own_email(self):
form = {"value": "[email protected]"}
form = {"value": "[email protected]", "down": "true", "up": "true"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
doc = json.loads(c.value)
@ -103,3 +105,10 @@ class AddEmailTestCase(BaseTestCase):
# Email should *not* have been sent
self.assertEqual(len(mail.outbox), 0)
def test_it_rejects_unchecked_up_and_dwon(self):
form = {"value": "[email protected]"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertContains(r, "Please select at least one.")

+ 39
- 0
hc/front/tests/test_add_matrix.py View File

@ -0,0 +1,39 @@
from unittest.mock import patch
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
@override_settings(MATRIX_ACCESS_TOKEN="foo")
@override_settings(MATRIX_HOMESERVER="fake-homeserver")
class AddMatrixTestCase(BaseTestCase):
def setUp(self):
super(AddMatrixTestCase, self).setUp()
self.url = "/projects/%s/add_matrix/" % self.project.code
@override_settings(MATRIX_ACCESS_TOKEN="foo")
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Integration Settings", status_code=200)
@patch("hc.front.forms.requests.post")
def test_it_works(self, mock_post):
mock_post.return_value.json.return_value = {"room_id": "fake-room-id"}
form = {"alias": "!foo:example.org"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "matrix")
self.assertEqual(c.value, "fake-room-id")
self.assertEqual(c.project, self.project)
@override_settings(MATRIX_ACCESS_TOKEN=None)
def test_it_requires_access_token(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 7
- 5
hc/front/tests/test_add_mattermost.py View File

@ -1,21 +1,23 @@
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
class AddMattermostTestCase(BaseTestCase):
def setUp(self):
super(AddMattermostTestCase, self).setUp()
self.url = "/projects/%s/add_mattermost/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_mattermost/")
r = self.client.get(self.url)
self.assertContains(r, "Integration Settings", status_code=200)
def test_it_works(self):
form = {"value": "http://example.org"}
self.client.login(username="[email protected]", password="password")
r = self.client.post("/integrations/add_mattermost/", form)
self.assertRedirects(r, "/integrations/")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "mattermost")


+ 25
- 0
hc/front/tests/test_add_msteams.py View File

@ -0,0 +1,25 @@
from hc.api.models import Channel
from hc.test import BaseTestCase
class AddMsTeamsTestCase(BaseTestCase):
def setUp(self):
super(AddMsTeamsTestCase, self).setUp()
self.url = "/projects/%s/add_msteams/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Integration Settings", status_code=200)
def test_it_works(self):
form = {"value": "https://example.com/foo"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "msteams")
self.assertEqual(c.value, "https://example.com/foo")
self.assertEqual(c.project, self.project)

+ 24
- 6
hc/front/tests/test_add_opsgenie.py View File

@ -1,9 +1,13 @@
import json
from hc.api.models import Channel
from hc.test import BaseTestCase
class AddOpsGenieTestCase(BaseTestCase):
url = "/integrations/add_opsgenie/"
def setUp(self):
super(AddOpsGenieTestCase, self).setUp()
self.url = "/projects/%s/add_opsgenie/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -11,22 +15,36 @@ class AddOpsGenieTestCase(BaseTestCase):
self.assertContains(r, "escalation policies and incident tracking")
def test_it_works(self):
form = {"value": "123456"}
form = {"key": "123456", "region": "us"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "opsgenie")
self.assertEqual(c.value, "123456")
payload = json.loads(c.value)
self.assertEqual(payload["key"], "123456")
self.assertEqual(payload["region"], "us")
self.assertEqual(c.project, self.project)
def test_it_trims_whitespace(self):
form = {"value": " 123456 "}
form = {"key": " 123456 ", "region": "us"}
self.client.login(username="[email protected]", password="password")
self.client.post(self.url, form)
c = Channel.objects.get()
payload = json.loads(c.value)
self.assertEqual(payload["key"], "123456")
def test_it_saves_eu_region(self):
form = {"key": "123456", "region": "eu"}
self.client.login(username="[email protected]", password="password")
self.client.post(self.url, form)
c = Channel.objects.get()
self.assertEqual(c.value, "123456")
payload = json.loads(c.value)
self.assertEqual(payload["region"], "eu")

+ 4
- 2
hc/front/tests/test_add_pagerteam.py View File

@ -3,7 +3,9 @@ from hc.test import BaseTestCase
class AddPagerTeamTestCase(BaseTestCase):
url = "/integrations/add_pagerteam/"
def setUp(self):
super(AddPagerTeamTestCase, self).setUp()
self.url = "/projects/%s/add_pagerteam/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -15,7 +17,7 @@ class AddPagerTeamTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "pagerteam")


+ 4
- 2
hc/front/tests/test_add_pagertree.py View File

@ -3,7 +3,9 @@ from hc.test import BaseTestCase
class AddPagerTreeTestCase(BaseTestCase):
url = "/integrations/add_pagertree/"
def setUp(self):
super(AddPagerTreeTestCase, self).setUp()
self.url = "/projects/%s/add_pagertree/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -15,7 +17,7 @@ class AddPagerTreeTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "pagertree")


+ 15
- 24
hc/front/tests/test_add_pd.py View File

@ -1,43 +1,34 @@
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
@override_settings(PD_VENDOR_KEY="foo")
class AddPdTestCase(BaseTestCase):
url = "/integrations/add_pd/"
def setUp(self):
super(AddPdTestCase, self).setUp()
self.url = "/projects/%s/add_pd/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "If your team uses")
self.assertContains(r, "Paste the Integration Key down below")
def test_it_works(self):
session = self.client.session
session["pd"] = "1234567890AB" # 12 characters
session.save()
# Integration key is 32 characters long
form = {"value": "12345678901234567890123456789012"}
self.client.login(username="[email protected]", password="password")
url = "/integrations/add_pd/1234567890AB/?service_key=123"
r = self.client.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "pd")
self.assertEqual(c.pd_service_key, "123")
self.assertEqual(c.project, self.project)
self.assertEqual(c.value, "12345678901234567890123456789012")
def test_it_validates_code(self):
session = self.client.session
session["pd"] = "1234567890AB" # 12 characters
session.save()
def test_it_trims_whitespace(self):
form = {"value": " 123456 "}
self.client.login(username="[email protected]", password="password")
url = "/integrations/add_pd/XXXXXXXXXXXX/?service_key=123"
r = self.client.get(url)
self.assertEqual(r.status_code, 400)
@override_settings(PD_VENDOR_KEY=None)
def test_it_requires_vendor_key(self):
r = self.client.get("/integrations/add_pd/")
self.assertEqual(r.status_code, 404)
self.client.post(self.url, form)
c = Channel.objects.get()
self.assertEqual(c.value, "123456")

+ 32
- 0
hc/front/tests/test_add_pdc.py View File

@ -0,0 +1,32 @@
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
@override_settings(PD_VENDOR_KEY="foo")
class AddPdConnectTestCase(BaseTestCase):
def setUp(self):
super(AddPdConnectTestCase, self).setUp()
self.url = "/projects/%s/add_pdc/" % self.project.code
def test_it_works(self):
session = self.client.session
session["pd"] = "1234567890AB" # 12 characters
session.save()
self.client.login(username="[email protected]", password="password")
url = self.url + "1234567890AB/?service_key=123"
r = self.client.get(url, follow=True)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "pd")
self.assertEqual(c.pd_service_key, "123")
self.assertEqual(c.project, self.project)
@override_settings(PD_VENDOR_KEY=None)
def test_it_requires_vendor_key(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 26
- 0
hc/front/tests/test_add_pdc_complete.py View File

@ -0,0 +1,26 @@
from django.test.utils import override_settings
from hc.test import BaseTestCase
@override_settings(PD_VENDOR_KEY="foo")
class AddPdcCompleteTestCase(BaseTestCase):
def setUp(self):
super(AddPdcCompleteTestCase, self).setUp()
self.url = "/projects/%s/add_pdc/" % self.project.code
self.url += "XXXXXXXXXXXX/?service_key=123"
def test_it_validates_code(self):
session = self.client.session
session["pd"] = "1234567890AB"
session.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 400)
@override_settings(PD_VENDOR_KEY=None)
def test_it_requires_vendor_key(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 21
- 0
hc/front/tests/test_add_pdc_help.py View File

@ -0,0 +1,21 @@
from django.test.utils import override_settings
from hc.test import BaseTestCase
@override_settings(PD_VENDOR_KEY="foo")
class AddPdcHelpTestCase(BaseTestCase):
url = "/integrations/add_pdc/"
def test_instructions_work_when_not_logged_in(self):
r = self.client.get(self.url)
self.assertContains(r, "Before adding PagerDuty integration, please log")
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "If your team uses")
@override_settings(PD_VENDOR_KEY=None)
def test_it_requires_vendor_key(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 4
- 42
hc/front/tests/test_add_pushbullet.py View File

@ -1,14 +1,12 @@
import json
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
from mock import patch
@override_settings(PUSHBULLET_CLIENT_ID="t1", PUSHBULLET_CLIENT_SECRET="s1")
class AddPushbulletTestCase(BaseTestCase):
url = "/integrations/add_pushbullet/"
def setUp(self):
super(AddPushbulletTestCase, self).setUp()
self.url = "/projects/%s/add_pushbullet/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -17,46 +15,10 @@ class AddPushbulletTestCase(BaseTestCase):
self.assertContains(r, "Connect Pushbullet")
# There should now be a key in session
self.assertTrue("pushbullet" in self.client.session)
self.assertTrue("add_pushbullet" in self.client.session)
@override_settings(PUSHBULLET_CLIENT_ID=None)
def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
@patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["pushbullet"] = "foo"
session.save()
oauth_response = {"access_token": "test-token"}
mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response
url = self.url + "?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, "/integrations/")
self.assertContains(r, "The Pushbullet integration has been added!")
ch = Channel.objects.get()
self.assertEqual(ch.value, "test-token")
self.assertEqual(ch.project, self.project)
# Session should now be clean
self.assertFalse("pushbullet" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["pushbullet"] = "foo"
session.save()
url = self.url + "?code=12345678&state=bar"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 400)

+ 71
- 0
hc/front/tests/test_add_pushbullet_complete.py View File

@ -0,0 +1,71 @@
import json
from unittest.mock import patch
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
@override_settings(PUSHBULLET_CLIENT_ID="t1", PUSHBULLET_CLIENT_SECRET="s1")
class AddPushbulletTestCase(BaseTestCase):
url = "/integrations/add_pushbullet/"
@patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["add_pushbullet"] = ("foo", str(self.project.code))
session.save()
oauth_response = {"access_token": "test-token"}
mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response
url = self.url + "?code=12345678&state=foo&project=%s" % self.project.code
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, self.channels_url)
self.assertContains(r, "The Pushbullet integration has been added!")
ch = Channel.objects.get()
self.assertEqual(ch.value, "test-token")
self.assertEqual(ch.project, self.project)
# Session should now be clean
self.assertFalse("add_pushbullet" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["pushbullet"] = ("foo", str(self.project.code))
session.save()
url = self.url + "?code=12345678&state=bar&project=%s" % self.project.code
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 403)
@patch("hc.front.views.requests.post")
def test_it_handles_denial(self, mock_post):
session = self.client.session
session["add_pushbullet"] = ("foo", str(self.project.code))
session.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url + "?error=access_denied", follow=True)
self.assertRedirects(r, self.channels_url)
self.assertContains(r, "Pushbullet setup was cancelled")
self.assertEqual(Channel.objects.count(), 0)
# Session should now be clean
self.assertFalse("add_pushbullet" in self.client.session)
@override_settings(PUSHBULLET_CLIENT_ID=None)
def test_it_requires_client_id(self):
url = self.url + "?code=12345678&state=bar&project=%s" % self.project.code
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 404)

+ 20
- 22
hc/front/tests/test_add_pushover.py View File

@ -7,32 +7,30 @@ from hc.test import BaseTestCase
PUSHOVER_API_TOKEN="token", PUSHOVER_SUBSCRIPTION_URL="http://example.org"
)
class AddPushoverTestCase(BaseTestCase):
def setUp(self):
super(AddPushoverTestCase, self).setUp()
self.url = "/projects/%s/add_pushover/" % self.project.code
@override_settings(PUSHOVER_API_TOKEN=None)
def test_it_requires_api_token(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_pushover/")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
def test_instructions_work_without_login(self):
r = self.client.get("/integrations/add_pushover/")
self.assertContains(r, "Setup Guide")
def test_it_shows_form(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_pushover/")
r = self.client.get(self.url)
self.assertContains(r, "Subscribe with Pushover")
def test_post_redirects(self):
self.client.login(username="[email protected]", password="password")
payload = {"po_priority": 2}
r = self.client.post("/integrations/add_pushover/", form=payload)
r = self.client.post(self.url, form=payload)
self.assertEqual(r.status_code, 302)
def test_post_requires_authenticated_user(self):
payload = {"po_priority": 2}
r = self.client.post("/integrations/add_pushover/", form=payload)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Setup Guide")
def test_it_requires_authenticated_user(self):
r = self.client.get(self.url)
self.assertRedirects(r, "/accounts/login/?next=" + self.url)
def test_it_adds_channel(self):
self.client.login(username="[email protected]", password="password")
@ -41,9 +39,9 @@ class AddPushoverTestCase(BaseTestCase):
session["pushover"] = "foo"
session.save()
params = "pushover_user_key=a&state=foo&prio=0&prio_up=-1"
r = self.client.get("/integrations/add_pushover/?%s" % params)
self.assertEqual(r.status_code, 302)
params = "?pushover_user_key=a&state=foo&prio=0&prio_up=-1"
r = self.client.get(self.url + params, follow=True)
self.assertRedirects(r, self.channels_url)
channel = Channel.objects.get()
self.assertEqual(channel.value, "a|0|-1")
@ -56,8 +54,8 @@ class AddPushoverTestCase(BaseTestCase):
session["pushover"] = "foo"
session.save()
params = "pushover_user_key=a&state=foo&prio=abc"
r = self.client.get("/integrations/add_pushover/?%s" % params)
params = "?pushover_user_key=a&state=foo&prio=abc"
r = self.client.get(self.url + params)
self.assertEqual(r.status_code, 400)
def test_it_validates_priority_up(self):
@ -67,8 +65,8 @@ class AddPushoverTestCase(BaseTestCase):
session["pushover"] = "foo"
session.save()
params = "pushover_user_key=a&state=foo&prio_up=abc"
r = self.client.get("/integrations/add_pushover/?%s" % params)
params = "?pushover_user_key=a&state=foo&prio_up=abc"
r = self.client.get(self.url + params)
self.assertEqual(r.status_code, 400)
def test_it_validates_state(self):
@ -78,6 +76,6 @@ class AddPushoverTestCase(BaseTestCase):
session["pushover"] = "foo"
session.save()
params = "pushover_user_key=a&state=INVALID&prio=0"
r = self.client.get("/integrations/add_pushover/?%s" % params)
self.assertEqual(r.status_code, 400)
params = "?pushover_user_key=a&state=INVALID&prio=0"
r = self.client.get(self.url + params)
self.assertEqual(r.status_code, 403)

+ 18
- 0
hc/front/tests/test_add_pushover_help.py View File

@ -0,0 +1,18 @@
from django.test.utils import override_settings
from hc.test import BaseTestCase
@override_settings(
PUSHOVER_API_TOKEN="token", PUSHOVER_SUBSCRIPTION_URL="http://example.org"
)
class AddPushoverHelpTestCase(BaseTestCase):
url = "/integrations/add_pushover/"
@override_settings(PUSHOVER_API_TOKEN=None)
def test_it_requires_api_token(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
def test_instructions_work_without_login(self):
r = self.client.get(self.url)
self.assertContains(r, "Setup Guide")

+ 55
- 0
hc/front/tests/test_add_shell.py View File

@ -0,0 +1,55 @@
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
@override_settings(SHELL_ENABLED=True)
class AddShellTestCase(BaseTestCase):
def setUp(self):
super(AddShellTestCase, self).setUp()
self.url = "/projects/%s/add_shell/" % self.project.code
@override_settings(SHELL_ENABLED=False)
def test_it_is_disabled_by_default(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Executes a local shell command")
def test_it_adds_two_commands_and_redirects(self):
form = {"cmd_down": "logger down", "cmd_up": "logger up"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.project, self.project)
self.assertEqual(c.cmd_down, "logger down")
self.assertEqual(c.cmd_up, "logger up")
def test_it_adds_webhook_using_team_access(self):
form = {"cmd_down": "logger down", "cmd_up": "logger up"}
# Logging in as bob, not alice. Bob has team access so this
# should work.
self.client.login(username="[email protected]", password="password")
self.client.post(self.url, form)
c = Channel.objects.get()
self.assertEqual(c.project, self.project)
self.assertEqual(c.cmd_down, "logger down")
def test_it_handles_empty_down_command(self):
form = {"cmd_down": "", "cmd_up": "logger up"}
self.client.login(username="[email protected]", password="password")
self.client.post(self.url, form)
c = Channel.objects.get()
self.assertEqual(c.cmd_down, "")
self.assertEqual(c.cmd_up, "logger up")

+ 8
- 9
hc/front/tests/test_add_slack.py View File

@ -1,33 +1,32 @@
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
class AddSlackTestCase(BaseTestCase):
@override_settings(SLACK_CLIENT_ID=None)
def setUp(self):
super(AddSlackTestCase, self).setUp()
self.url = "/projects/%s/add_slack/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_slack/")
r = self.client.get(self.url)
self.assertContains(r, "Integration Settings", status_code=200)
@override_settings(SLACK_CLIENT_ID=None)
def test_it_works(self):
form = {"value": "http://example.org"}
self.client.login(username="[email protected]", password="password")
r = self.client.post("/integrations/add_slack/", form)
self.assertRedirects(r, "/integrations/")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "slack")
self.assertEqual(c.value, "http://example.org")
self.assertEqual(c.project, self.project)
@override_settings(SLACK_CLIENT_ID=None)
def test_it_rejects_bad_url(self):
form = {"value": "not an URL"}
self.client.login(username="[email protected]", password="password")
r = self.client.post("/integrations/add_slack/", form)
r = self.client.post(self.url, form)
self.assertContains(r, "Enter a valid URL")

+ 15
- 71
hc/front/tests/test_add_slack_btn.py View File

@ -1,84 +1,28 @@
import json
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
from mock import patch
@override_settings(SLACK_CLIENT_ID="fake-client-id")
class AddSlackBtnTestCase(BaseTestCase):
@override_settings(SLACK_CLIENT_ID="foo")
def test_it_prepares_login_link(self):
r = self.client.get("/integrations/add_slack/")
self.assertContains(r, "Before adding Slack integration", status_code=200)
def setUp(self):
super(AddSlackBtnTestCase, self).setUp()
self.url = "/projects/%s/add_slack_btn/" % self.project.code
self.assertContains(r, "?next=/integrations/add_slack/")
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Setup Guide", status_code=200)
@override_settings(SLACK_CLIENT_ID="foo")
def test_slack_button(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_slack/")
self.assertContains(r, "slack.com/oauth/authorize", status_code=200)
r = self.client.get(self.url)
self.assertContains(r, "slack.com/oauth/v2/authorize", status_code=200)
# There should now be a key in session
self.assertTrue("slack" in self.client.session)
@patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["slack"] = "foo"
session.save()
oauth_response = {
"ok": True,
"team_name": "foo",
"incoming_webhook": {"url": "http://example.org", "channel": "bar"},
}
mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response
url = "/integrations/add_slack_btn/?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, "/integrations/")
self.assertContains(r, "The Slack integration has been added!")
ch = Channel.objects.get()
self.assertEqual(ch.slack_team, "foo")
self.assertEqual(ch.slack_channel, "bar")
self.assertEqual(ch.slack_webhook_url, "http://example.org")
self.assertEqual(ch.project, self.project)
# Session should now be clean
self.assertFalse("slack" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["slack"] = "foo"
session.save()
url = "/integrations/add_slack_btn/?code=12345678&state=bar"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 400)
@patch("hc.front.views.requests.post")
def test_it_handles_oauth_error(self, mock_post):
session = self.client.session
session["slack"] = "foo"
session.save()
oauth_response = {"ok": False, "error": "something went wrong"}
mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response
url = "/integrations/add_slack_btn/?code=12345678&state=foo"
self.assertTrue("add_slack" in self.client.session)
@override_settings(SLACK_CLIENT_ID=None)
def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, "/integrations/")
self.assertContains(r, "something went wrong")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 75
- 0
hc/front/tests/test_add_slack_complete.py View File

@ -0,0 +1,75 @@
import json
from unittest.mock import patch
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
@override_settings(SLACK_CLIENT_ID="fake-client-id")
class AddSlackCompleteTestCase(BaseTestCase):
@patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["add_slack"] = ("foo", str(self.project.code))
session.save()
oauth_response = {
"ok": True,
"team_name": "foo",
"incoming_webhook": {"url": "http://example.org", "channel": "bar"},
}
mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response
url = "/integrations/add_slack_btn/?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, self.channels_url)
self.assertContains(r, "The Slack integration has been added!")
ch = Channel.objects.get()
self.assertEqual(ch.slack_team, "foo")
self.assertEqual(ch.slack_channel, "bar")
self.assertEqual(ch.slack_webhook_url, "http://example.org")
self.assertEqual(ch.project, self.project)
# Session should now be clean
self.assertFalse("add_slack" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["add_slack"] = ("foo", str(self.project.code))
session.save()
url = "/integrations/add_slack_btn/?code=12345678&state=bar"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 403)
@patch("hc.front.views.requests.post")
def test_it_handles_oauth_error(self, mock_post):
session = self.client.session
session["add_slack"] = ("foo", str(self.project.code))
session.save()
oauth_response = {"ok": False, "error": "something went wrong"}
mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response
url = "/integrations/add_slack_btn/?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True)
self.assertRedirects(r, self.channels_url)
self.assertContains(r, "something went wrong")
@override_settings(SLACK_CLIENT_ID=None)
def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_slack_btn/?code=12345678&state=foo")
self.assertEqual(r.status_code, 404)

+ 14
- 0
hc/front/tests/test_add_slack_help.py View File

@ -0,0 +1,14 @@
from django.test.utils import override_settings
from hc.test import BaseTestCase
@override_settings(SLACK_CLIENT_ID="fake-client-id")
class AddSlackHelpTestCase(BaseTestCase):
def test_instructions_work(self):
r = self.client.get("/integrations/add_slack/")
self.assertContains(r, "Setup Guide", status_code=200)
@override_settings(SLACK_CLIENT_ID=None)
def test_it_requires_client_id(self):
r = self.client.get("/integrations/add_slack/")
self.assertEqual(r.status_code, 404)

+ 5
- 3
hc/front/tests/test_add_sms.py View File

@ -5,7 +5,9 @@ from hc.test import BaseTestCase
@override_settings(TWILIO_ACCOUNT="foo", TWILIO_AUTH="foo", TWILIO_FROM="123")
class AddSmsTestCase(BaseTestCase):
url = "/integrations/add_sms/"
def setUp(self):
super(AddSmsTestCase, self).setUp()
self.url = "/projects/%s/add_sms/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -26,7 +28,7 @@ class AddSmsTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "sms")
@ -53,5 +55,5 @@ class AddSmsTestCase(BaseTestCase):
@override_settings(TWILIO_AUTH=None)
def test_it_requires_credentials(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_sms/")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 22
- 3
hc/front/tests/test_add_telegram.py View File

@ -1,9 +1,12 @@
from unittest.mock import patch
from django.core import signing
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
from mock import patch
@override_settings(TELEGRAM_TOKEN="fake-token")
class AddTelegramTestCase(BaseTestCase):
url = "/integrations/add_telegram/"
@ -12,6 +15,14 @@ class AddTelegramTestCase(BaseTestCase):
r = self.client.get(self.url)
self.assertContains(r, "start@ExampleBot")
@override_settings(TELEGRAM_TOKEN=None)
def test_it_requires_token(self):
payload = signing.dumps((123, "group", "My Group"))
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url + "?" + payload)
self.assertEqual(r.status_code, 404)
def test_it_shows_confirmation(self):
payload = signing.dumps((123, "group", "My Group"))
@ -23,8 +34,9 @@ class AddTelegramTestCase(BaseTestCase):
payload = signing.dumps((123, "group", "My Group"))
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url + "?" + payload, {})
self.assertRedirects(r, "/integrations/")
form = {"project": str(self.project.code)}
r = self.client.post(self.url + "?" + payload, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "telegram")
@ -33,6 +45,13 @@ class AddTelegramTestCase(BaseTestCase):
self.assertEqual(c.telegram_name, "My Group")
self.assertEqual(c.project, self.project)
def test_it_handles_bad_signature(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url + "?bad-signature")
self.assertContains(r, "Incorrect Link")
self.assertFalse(Channel.objects.exists())
@patch("hc.api.transports.requests.request")
def test_it_sends_invite(self, mock_get):
data = {


+ 12
- 5
hc/front/tests/test_add_trello.py View File

@ -5,16 +5,17 @@ from hc.api.models import Channel
from hc.test import BaseTestCase
class AddPagerTreeTestCase(BaseTestCase):
url = "/integrations/add_trello/"
@override_settings(TRELLO_APP_KEY="foo")
class AddTrelloTestCase(BaseTestCase):
def setUp(self):
super(AddTrelloTestCase, self).setUp()
self.url = "/projects/%s/add_trello/" % self.project.code
@override_settings(TRELLO_APP_KEY="foo")
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Trello")
@override_settings(TRELLO_APP_KEY="foo")
def test_it_works(self):
form = {
"settings": json.dumps(
@ -29,9 +30,15 @@ class AddPagerTreeTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "trello")
self.assertEqual(c.trello_token, "fake-token")
self.assertEqual(c.project, self.project)
@override_settings(TRELLO_APP_KEY=None)
def test_it_requires_trello_app_key(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 4
- 2
hc/front/tests/test_add_victorops.py View File

@ -3,7 +3,9 @@ from hc.test import BaseTestCase
class AddVictorOpsTestCase(BaseTestCase):
url = "/integrations/add_victorops/"
def setUp(self):
super(AddVictorOpsTestCase, self).setUp()
self.url = "/projects/%s/add_victorops/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -15,7 +17,7 @@ class AddVictorOpsTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "victorops")


+ 40
- 7
hc/front/tests/test_add_webhook.py View File

@ -3,13 +3,31 @@ from hc.test import BaseTestCase
class AddWebhookTestCase(BaseTestCase):
url = "/integrations/add_webhook/"
def setUp(self):
super(AddWebhookTestCase, self).setUp()
self.url = "/projects/%s/add_webhook/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Executes an HTTP request")
def test_it_saves_name(self):
form = {
"name": "Call foo.com",
"method_down": "GET",
"url_down": "http://foo.com",
"method_up": "GET",
"url_up": "",
}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.name, "Call foo.com")
def test_it_adds_two_webhook_urls_and_redirects(self):
form = {
"method_down": "GET",
@ -20,7 +38,7 @@ class AddWebhookTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.project, self.project)
@ -95,7 +113,7 @@ class AddWebhookTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.down_webhook_spec["body"], "hello")
@ -110,7 +128,7 @@ class AddWebhookTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(
@ -122,12 +140,12 @@ class AddWebhookTestCase(BaseTestCase):
form = {
"method_down": "GET",
"url_down": "http://example.org",
"headers_down": "invalid-headers",
"headers_down": "invalid-header\nfoo:bar",
"method_up": "GET",
}
r = self.client.post(self.url, form)
self.assertContains(r, """invalid-headers""")
self.assertContains(r, """invalid-header""")
self.assertEqual(Channel.objects.count(), 0)
def test_it_strips_headers(self):
@ -140,7 +158,22 @@ class AddWebhookTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.down_webhook_spec["headers"], {"test": "123"})
def test_it_rejects_both_empty(self):
self.client.login(username="[email protected]", password="password")
form = {
"method_down": "GET",
"url_down": "",
"method_up": "GET",
"url_up": "",
}
r = self.client.post(self.url, form)
self.assertContains(r, "Enter a valid URL.")
self.assertEqual(Channel.objects.count(), 0)

+ 5
- 3
hc/front/tests/test_add_whatsapp.py View File

@ -12,7 +12,9 @@ TEST_CREDENTIALS = {
@override_settings(**TEST_CREDENTIALS)
class AddWhatsAppTestCase(BaseTestCase):
url = "/integrations/add_whatsapp/"
def setUp(self):
super(AddWhatsAppTestCase, self).setUp()
self.url = "/projects/%s/add_whatsapp/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
@ -38,7 +40,7 @@ class AddWhatsAppTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "whatsapp")
@ -53,7 +55,7 @@ class AddWhatsAppTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "whatsapp")


+ 80
- 0
hc/front/tests/test_add_zulip.py View File

@ -0,0 +1,80 @@
from hc.api.models import Channel
from hc.test import BaseTestCase
class AddZulipTestCase(BaseTestCase):
def setUp(self):
super(AddZulipTestCase, self).setUp()
self.url = "/projects/%s/add_zulip/" % self.project.code
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "open-source group chat app")
def test_it_works(self):
form = {
"bot_email": "[email protected]",
"api_key": "fake-key",
"mtype": "stream",
"to": "general",
}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
c = Channel.objects.get()
self.assertEqual(c.kind, "zulip")
self.assertEqual(c.zulip_bot_email, "[email protected]")
self.assertEqual(c.zulip_api_key, "fake-key")
self.assertEqual(c.zulip_type, "stream")
self.assertEqual(c.zulip_to, "general")
def test_it_rejects_bad_email(self):
form = {
"bot_email": "not@an@email",
"api_key": "fake-key",
"mtype": "stream",
"to": "general",
}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertContains(r, "Enter a valid email address.")
def test_it_rejects_missing_api_key(self):
form = {
"bot_email": "[email protected]",
"api_key": "",
"mtype": "stream",
"to": "general",
}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertContains(r, "This field is required.")
def test_it_rejects_bad_mtype(self):
form = {
"bot_email": "[email protected]",
"api_key": "fake-key",
"mtype": "this-should-not-work",
"to": "general",
}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
def test_it_rejects_missing_stream_name(self):
form = {
"bot_email": "[email protected]",
"api_key": "fake-key",
"mtype": "stream",
"to": "",
}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertContains(r, "This field is required.")

+ 6
- 3
hc/front/tests/test_channel_checks.py View File

@ -1,4 +1,4 @@
from hc.api.models import Channel
from hc.api.models import Channel, Check
from hc.test import BaseTestCase
@ -9,11 +9,14 @@ class ChannelChecksTestCase(BaseTestCase):
self.channel.value = "[email protected]"
self.channel.save()
Check.objects.create(project=self.project, name="Database Backups")
def test_it_works(self):
url = "/integrations/%s/checks/" % self.channel.code
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertContains(r, "Database Backups")
self.assertContains(r, "Assign Checks to Integration", status_code=200)
def test_team_access_works(self):
@ -31,7 +34,7 @@ class ChannelChecksTestCase(BaseTestCase):
url = "/integrations/%s/checks/" % self.channel.code
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
assert r.status_code == 403
self.assertEqual(r.status_code, 404)
def test_missing_channel(self):
# Valid UUID but there is no channel for it:
@ -39,4 +42,4 @@ class ChannelChecksTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
assert r.status_code == 404
self.assertEqual(r.status_code, 404)

+ 26
- 16
hc/front/tests/test_channels.py View File

@ -17,17 +17,28 @@ class ChannelsTestCase(BaseTestCase):
ch.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
r = self.client.get(self.channels_url)
self.assertContains(r, "foo-team", status_code=200)
self.assertContains(r, "#bar")
def test_it_shows_webhook_post_data(self):
ch = Channel(kind="webhook", project=self.project)
ch.value = "http://down.example.com\nhttp://up.example.com\nfoobar"
ch.value = json.dumps(
{
"method_down": "POST",
"url_down": "http://down.example.com",
"body_down": "foobar",
"headers_down": {},
"method_up": "GET",
"url_up": "http://up.example.com",
"body_up": "",
"headers_up": {},
}
)
ch.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
r = self.client.get(self.channels_url)
self.assertEqual(r.status_code, 200)
# These are inside a modal:
@ -41,7 +52,7 @@ class ChannelsTestCase(BaseTestCase):
ch.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
r = self.client.get(self.channels_url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "(normal priority)")
@ -58,7 +69,7 @@ class ChannelsTestCase(BaseTestCase):
n.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
r = self.client.get(self.channels_url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Disabled")
@ -68,7 +79,7 @@ class ChannelsTestCase(BaseTestCase):
channel.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
r = self.client.get(self.channels_url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Unconfirmed")
@ -80,7 +91,7 @@ class ChannelsTestCase(BaseTestCase):
channel.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
r = self.client.get(self.channels_url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "(down only)")
@ -92,25 +103,24 @@ class ChannelsTestCase(BaseTestCase):
channel.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
r = self.client.get(self.channels_url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "(up only)")
def test_it_shows_sms_label(self):
def test_it_shows_sms_number(self):
ch = Channel(kind="sms", project=self.project)
ch.value = json.dumps({"value": "+123", "label": "My Phone"})
ch.value = json.dumps({"value": "+123"})
ch.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
r = self.client.get(self.channels_url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "SMS to +123")
def test_it_requires_current_project(self):
self.profile.current_project = None
self.profile.save()
def test_it_shows_channel_issues_indicator(self):
Channel.objects.create(kind="sms", project=self.project, last_error="x")
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/")
self.assertRedirects(r, "/")
r = self.client.get(self.channels_url)
self.assertContains(r, "broken-channels", status_code=200)

+ 18
- 0
hc/front/tests/test_copy.py View File

@ -0,0 +1,18 @@
from hc.api.models import Check
from hc.test import BaseTestCase
class CopyCheckTestCase(BaseTestCase):
def setUp(self):
super(CopyCheckTestCase, self).setUp()
self.check = Check(project=self.project)
self.check.name = "Foo"
self.check.save()
self.copy_url = "/checks/%s/copy/" % self.check.code
def test_it_works(self):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.copy_url, follow=True)
self.assertContains(r, "This is a brand new check")
self.assertContains(r, "Foo (copy)")

+ 1
- 1
hc/front/tests/test_cron_preview.py View File

@ -1,7 +1,7 @@
from datetime import datetime
from unittest.mock import patch
from hc.test import BaseTestCase
from mock import patch
import pytz


+ 0
- 3
hc/front/tests/test_details.py View File

@ -37,9 +37,6 @@ class DetailsTestCase(BaseTestCase):
self.assertContains(r, "Cron Expression", status_code=200)
def test_it_allows_cross_team_access(self):
self.bobs_profile.current_project = None
self.bobs_profile.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)


+ 84
- 0
hc/front/tests/test_edit_webhook.py View File

@ -0,0 +1,84 @@
import json
from hc.api.models import Channel
from hc.test import BaseTestCase
class EditWebhookTestCase(BaseTestCase):
def setUp(self):
super(EditWebhookTestCase, self).setUp()
definition = {
"method_down": "GET",
"url_down": "http://example.org/down",
"body_down": "$NAME is down",
"headers_down": {"User-Agent": "My-Custom-UA"},
"method_up": "GET",
"url_up": "http://example.org/up",
"body_up": "$NAME is up",
"headers_up": {},
}
self.channel = Channel(project=self.project, kind="webhook")
self.channel.name = "Call example.org"
self.channel.value = json.dumps(definition)
self.channel.save()
self.url = "/integrations/%s/edit_webhook/" % self.channel.code
def test_it_shows_form(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Webhook Settings")
self.assertContains(r, "Call example.org")
# down
self.assertContains(r, "http://example.org/down")
self.assertContains(r, "My-Custom-UA")
self.assertContains(r, "$NAME is down")
# up
self.assertContains(r, "http://example.org/up")
self.assertContains(r, "$NAME is up")
def test_it_saves_form_and_redirects(self):
form = {
"name": "Call foo.com / bar.com",
"method_down": "POST",
"url_down": "http://foo.com",
"headers_down": "X-Foo: 1\nX-Bar: 2",
"body_down": "going down",
"method_up": "POST",
"url_up": "https://bar.com",
"headers_up": "Content-Type: text/plain",
"body_up": "going up",
}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
self.channel.refresh_from_db()
self.assertEqual(self.channel.name, "Call foo.com / bar.com")
down_spec = self.channel.down_webhook_spec
self.assertEqual(down_spec["method"], "POST")
self.assertEqual(down_spec["url"], "http://foo.com")
self.assertEqual(down_spec["body"], "going down")
self.assertEqual(down_spec["headers"], {"X-Foo": "1", "X-Bar": "2"})
up_spec = self.channel.up_webhook_spec
self.assertEqual(up_spec["method"], "POST")
self.assertEqual(up_spec["url"], "https://bar.com")
self.assertEqual(up_spec["body"], "going up")
self.assertEqual(up_spec["headers"], {"Content-Type": "text/plain"})
def test_it_requires_kind_webhook(self):
self.channel.kind = "email"
self.channel.value = "[email protected]"
self.channel.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 400)

+ 31
- 0
hc/front/tests/test_filtering_rules.py View File

@ -0,0 +1,31 @@
from hc.api.models import Check
from hc.test import BaseTestCase
class FilteringRulesTestCase(BaseTestCase):
def setUp(self):
super(FilteringRulesTestCase, self).setUp()
self.check = Check.objects.create(project=self.project)
self.url = "/checks/%s/filtering_rules/" % self.check.code
self.redirect_url = "/checks/%s/details/" % self.check.code
def test_it_works(self):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, data={"subject": "SUCCESS", "methods": "POST"})
self.assertRedirects(r, self.redirect_url)
self.check.refresh_from_db()
self.assertEqual(self.check.subject, "SUCCESS")
self.assertEqual(self.check.methods, "POST")
def test_it_clears_method(self):
self.check.method = "POST"
self.check.save()
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, data={"subject": "SUCCESS", "methods": ""})
self.assertRedirects(r, self.redirect_url)
self.check.refresh_from_db()
self.assertEqual(self.check.methods, "")

+ 9
- 5
hc/front/tests/test_log.py View File

@ -61,7 +61,7 @@ class LogTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Sent email alert to [email protected]", status_code=200)
self.assertContains(r, "Sent email to [email protected]", status_code=200)
def test_it_shows_pushover_notification(self):
ch = Channel.objects.create(kind="po", project=self.project)
@ -74,7 +74,14 @@ class LogTestCase(BaseTestCase):
def test_it_shows_webhook_notification(self):
ch = Channel(kind="webhook", project=self.project)
ch.value = "foo/$NAME"
ch.value = json.dumps(
{
"method_down": "GET",
"url_down": "foo/$NAME",
"body_down": "",
"headers_down": {},
}
)
ch.save()
Notification(owner=self.check, channel=ch, check_status="down").save()
@ -84,9 +91,6 @@ class LogTestCase(BaseTestCase):
self.assertContains(r, "Called webhook foo/$NAME", status_code=200)
def test_it_allows_cross_team_access(self):
self.bobs_profile.current_project = None
self.bobs_profile.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)

+ 43
- 0
hc/front/tests/test_metrics.py View File

@ -0,0 +1,43 @@
from hc.api.models import Check
from hc.test import BaseTestCase
class MetricsTestCase(BaseTestCase):
def setUp(self):
super(MetricsTestCase, self).setUp()
self.project.api_key_readonly = "R" * 32
self.project.save()
self.check = Check(project=self.project, name="Alice Was Here")
self.check.tags = "foo"
self.check.save()
key = "R" * 32
self.url = "/projects/%s/checks/metrics/%s" % (self.project.code, key)
def test_it_works(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'name="Alice Was Here"')
self.assertContains(r, 'tags="foo"')
self.assertContains(r, 'tag="foo"')
self.assertContains(r, "hc_checks_total 1")
def test_it_escapes_newline(self):
self.check.name = "Line 1\nLine2"
self.check.tags = "A\\C"
self.check.save()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Line 1\\nLine2")
self.assertContains(r, "A\\\\C")
def test_it_checks_api_key_length(self):
r = self.client.get(self.url + "R")
self.assertEqual(r.status_code, 400)
def test_it_checks_api_key(self):
url = "/projects/%s/checks/metrics/%s" % (self.project.code, "X" * 32)
r = self.client.get(url)
self.assertEqual(r.status_code, 403)

+ 16
- 4
hc/front/tests/test_my_checks.py View File

@ -18,16 +18,28 @@ class MyChecksTestCase(BaseTestCase):
r = self.client.get(self.url)
self.assertContains(r, "Alice Was Here", status_code=200)
def test_it_updates_current_project(self):
self.profile.current_project = None
# last_active_date should have been set
self.profile.refresh_from_db()
self.assertTrue(self.profile.last_active_date)
def test_it_bumps_last_active_date(self):
self.profile.last_active_date = timezone.now() - td(days=10)
self.profile.save()
self.client.login(username="[email protected]", password="password")
self.client.get(self.url)
# last_active_date should have been bumped
self.profile.refresh_from_db()
delta = timezone.now() - self.profile.last_active_date
self.assertTrue(delta.total_seconds() < 1)
def test_it_updates_session(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertEqual(self.profile.current_project, self.project)
self.assertEqual(self.client.session["last_project_id"], self.project.id)
def test_it_checks_access(self):
self.client.login(username="[email protected]", password="password")


+ 5
- 3
hc/front/tests/test_pause.py View File

@ -26,9 +26,6 @@ class PauseTestCase(BaseTestCase):
self.assertEqual(r.status_code, 405)
def test_it_allows_cross_team_access(self):
self.bobs_profile.current_project = None
self.bobs_profile.save()
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url)
self.assertRedirects(r, self.redirect_url)
@ -44,3 +41,8 @@ class PauseTestCase(BaseTestCase):
self.check.refresh_from_db()
self.assertEqual(self.check.last_start, None)
self.assertEqual(self.check.alert_after, None)
def test_it_does_not_redirect_ajax(self):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
self.assertEqual(r.status_code, 200)

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save