Harden auth, invites and deployment after security review
- Signed-out visitors get the same login redirect for missing and private projects, so the redirect no longer reveals private ones. - Production refuses the console email backend; sign-in codes must never reach the logs. - Database cache shared by all workers for allauth rate limits; invite sending throttled per sender and per recipient. - Email invite links are claimed on first use and the token rotated, so a token copied from request logs is worthless. - allauth password URLs removed; adding an email requires a fresh code and notifies existing addresses. - Markdown drops remote images until uploads exist. - Project and org slugs lowercased; only superusers can grant staff status or permissions in the admin. - Container runs as a non-root user; CI actions pinned to commits. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b9378b8397
commit
99f810d043
25 changed files with 281 additions and 42 deletions
|
|
@ -9,8 +9,9 @@ jobs:
|
||||||
# Adjust to match the labels of your Forgejo runner.
|
# Adjust to match the labels of your Forgejo runner.
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
# Actions are pinned to commits so a moved tag can't change what runs here.
|
||||||
- uses: https://github.com/astral-sh/setup-uv@v6
|
- uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
- uses: https://github.com/astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
|
||||||
with:
|
with:
|
||||||
version: "0.13.6"
|
version: "0.13.6"
|
||||||
- run: uv sync --locked
|
- run: uv sync --locked
|
||||||
|
|
@ -23,8 +24,8 @@ jobs:
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@master
|
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # master, 2026-10
|
||||||
- run: flyctl deploy --remote-only
|
- run: flyctl deploy --remote-only
|
||||||
env:
|
env:
|
||||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,17 @@ CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserv
|
||||||
|
|
||||||
FROM base AS prod
|
FROM base AS prod
|
||||||
COPY --from=litestream/litestream:0.5.17 /usr/local/bin/litestream /usr/local/bin/litestream
|
COPY --from=litestream/litestream:0.5.17 /usr/local/bin/litestream /usr/local/bin/litestream
|
||||||
|
# The app runs as this user; bin/start drops to it after making the data volume writable.
|
||||||
|
RUN useradd --system --uid 1000 --create-home app
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
uv sync --locked --no-install-project --no-dev
|
uv sync --locked --no-install-project --no-dev
|
||||||
COPY litestream.yml /etc/litestream.yml
|
COPY litestream.yml /etc/litestream.yml
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Placeholder settings: collectstatic needs the prod settings to import, nothing more.
|
||||||
RUN DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=collectstatic \
|
RUN DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=collectstatic \
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.dummy.EmailBackend \
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
ENV DJANGO_SETTINGS_MODULE=config.settings.prod
|
ENV DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,24 @@
|
||||||
# because release machines don't mount the volume that holds the SQLite database.
|
# because release machines don't mount the volume that holds the SQLite database.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# The volume is mounted owned by root. Take ownership for the app user, then rerun as it.
|
||||||
|
if [ "$(id -u)" = 0 ]; then
|
||||||
|
chown -R app:app "${INTENT_DATA_DIR:?}"
|
||||||
|
exec setpriv --reuid=app --regid=app --init-groups env HOME=/home/app "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
DB="${INTENT_DATA_DIR:?}/db.sqlite3"
|
DB="${INTENT_DATA_DIR:?}/db.sqlite3"
|
||||||
SERVER="gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -"
|
SERVER="gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -"
|
||||||
|
|
||||||
if [ -z "${LITESTREAM_BUCKET:-}" ]; then
|
if [ -z "${LITESTREAM_BUCKET:-}" ]; then
|
||||||
echo "LITESTREAM_BUCKET not set: running without backups" >&2
|
echo "LITESTREAM_BUCKET not set: running without backups" >&2
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
python manage.py createcachetable
|
||||||
exec $SERVER
|
exec $SERVER
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# On a fresh volume, pull the latest backup before anything touches the database.
|
# On a fresh volume, pull the latest backup before anything touches the database.
|
||||||
litestream restore -if-db-not-exists -if-replica-exists "$DB"
|
litestream restore -if-db-not-exists -if-replica-exists "$DB"
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
python manage.py createcachetable
|
||||||
exec litestream replicate -exec "$SERVER"
|
exec litestream replicate -exec "$SERVER"
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,15 @@ DATABASES = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Rate limits (allauth's and ours) live here, so the cache must be shared by every worker
|
||||||
|
# and survive restarts. The database is the only shared store on a one-machine deploy.
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
|
||||||
|
"LOCATION": "cache",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AUTH_USER_MODEL = "accounts.User"
|
AUTH_USER_MODEL = "accounts.User"
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
|
@ -100,6 +109,10 @@ ACCOUNT_LOGIN_BY_CODE_ENABLED = True
|
||||||
ACCOUNT_LOGIN_BY_CODE_SUPPORTS_RESEND = True
|
ACCOUNT_LOGIN_BY_CODE_SUPPORTS_RESEND = True
|
||||||
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 600
|
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 600
|
||||||
ACCOUNT_LOGOUT_ON_GET = False
|
ACCOUNT_LOGOUT_ON_GET = False
|
||||||
|
# Adding an email address adds a way to sign in, so ask for a fresh code first, and tell
|
||||||
|
# the existing addresses about it.
|
||||||
|
ACCOUNT_REAUTHENTICATION_REQUIRED = True
|
||||||
|
ACCOUNT_EMAIL_NOTIFICATIONS = True
|
||||||
SOCIALACCOUNT_PROVIDERS = {
|
SOCIALACCOUNT_PROVIDERS = {
|
||||||
"github": {"SCOPE": ["read:user", "user:email"]},
|
"github": {"SCOPE": ["read:user", "user:email"]},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,5 @@ SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405
|
||||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
|
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
|
||||||
|
|
||||||
DATA_DIR.mkdir(exist_ok=True) # noqa: F405
|
DATA_DIR.mkdir(exist_ok=True) # noqa: F405
|
||||||
|
# Avoid needing `createcachetable` for local development.
|
||||||
|
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ from .base import * # noqa: F403
|
||||||
|
|
||||||
if not SECRET_KEY: # noqa: F405
|
if not SECRET_KEY: # noqa: F405
|
||||||
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set")
|
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set")
|
||||||
|
# The console backend would print sign-in codes and invite links into the logs.
|
||||||
|
if EMAIL_BACKEND == "django.core.mail.backends.console.EmailBackend": # noqa: F405
|
||||||
|
raise ImproperlyConfigured("EMAIL_BACKEND must be set to a real email backend in production")
|
||||||
|
|
||||||
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
|
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
|
||||||
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
|
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from .base import * # noqa: F403
|
||||||
|
|
||||||
SECRET_KEY = "test-secret-key"
|
SECRET_KEY = "test-secret-key"
|
||||||
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||||
|
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
**STORAGES, # noqa: F405
|
**STORAGES, # noqa: F405
|
||||||
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
|
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,31 @@
|
||||||
|
import allauth.urls
|
||||||
|
from allauth.account import urls as allauth_account_urls
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import URLResolver, include, path
|
||||||
|
|
||||||
from intent.core import views
|
from intent.core import views
|
||||||
from intent.orgs.urls import invite_urlpatterns
|
from intent.orgs.urls import invite_urlpatterns
|
||||||
from intent.projects.views import project_create
|
from intent.projects.views import project_create
|
||||||
|
|
||||||
|
|
||||||
|
def _without_passwords(patterns):
|
||||||
|
"""allauth's URLs minus the password views: regular users sign in with a code only."""
|
||||||
|
return [p for p in patterns if not str(p.pattern).startswith("password/")]
|
||||||
|
|
||||||
|
|
||||||
|
account_urlpatterns = [
|
||||||
|
(
|
||||||
|
path("", include(_without_passwords(allauth_account_urls.urlpatterns)))
|
||||||
|
if isinstance(p, URLResolver) and p.urlconf_name is allauth_account_urls
|
||||||
|
else p
|
||||||
|
)
|
||||||
|
for p in allauth.urls.urlpatterns
|
||||||
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.home, name="home"),
|
path("", views.home, name="home"),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include(account_urlpatterns)),
|
||||||
path("invites/", include(invite_urlpatterns)),
|
path("invites/", include(invite_urlpatterns)),
|
||||||
# Organizations own the rest of the URL space (see RESERVED_SLUGS in orgs and projects).
|
# Organizations own the rest of the URL space (see RESERVED_SLUGS in orgs and projects).
|
||||||
path("<slug:org_slug>/new/", project_create, name="project_create"),
|
path("<slug:org_slug>/new/", project_create, name="project_create"),
|
||||||
|
|
|
||||||
13
docs/plan.md
13
docs/plan.md
|
|
@ -34,10 +34,10 @@ Decisions made in the concept are not repeated. These fill the gaps.
|
||||||
| Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. |
|
| Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. |
|
||||||
| Deleted users | Content remains, attributed to a "ghost" user. |
|
| Deleted users | Content remains, attributed to a "ghost" user. |
|
||||||
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
|
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
|
||||||
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
|
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and the first signed-in visitor claims the invite with whatever account they used. Claiming rotates the token, so the link (which appears in request logs) is dead after one use; the claimant then accepts or declines from their Invitations page. Unclaimed email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Sending is throttled (30 per sender per hour, 3 per recipient per org per hour). Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
|
||||||
| URLs | Organizations live at the root: `/<org>/`, `/<org>/<project>/`, `/<org>/<project>/features/INT-12/`. App routes are reserved slugs at both levels. |
|
| URLs | Organizations live at the root: `/<org>/`, `/<org>/<project>/`, `/<org>/<project>/features/INT-12/`. App routes are reserved slugs at both levels. |
|
||||||
| Project slug and prefix | Fixed after creation, since they appear in URLs and feature IDs. Name, visibility and open contribution stay editable. |
|
| Project slug and prefix | Fixed after creation, since they appear in URLs and feature IDs. Name, visibility and open contribution stay editable. |
|
||||||
| Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead. |
|
| Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead, for missing and hidden projects alike, so the redirect reveals nothing either. Markdown drops remote images (`<img>`) until uploads exist, so an author can't log readers' IP addresses. |
|
||||||
| Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. |
|
| Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. |
|
||||||
| Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. |
|
| Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. |
|
||||||
|
|
||||||
|
|
@ -46,8 +46,9 @@ Decisions made in the concept are not repeated. These fill the gaps.
|
||||||
| Topic | Default |
|
| Topic | Default |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. |
|
| Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. |
|
||||||
| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
|
| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password, and allauth's password URLs are not routed at all. Adding an email address requires re-entering a fresh code, and existing addresses are notified. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
|
||||||
| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). |
|
| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). Only superusers can grant staff or superuser status or permissions in the admin. |
|
||||||
|
| Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. |
|
||||||
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
|
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
|
||||||
| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. |
|
| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. |
|
||||||
| Markdown | `markdown-it-py` with source line mapping (`data-line` attributes on blocks) for line threads; output sanitized with `nh3`. Diffs via `difflib` rendered server-side. |
|
| Markdown | `markdown-it-py` with source line mapping (`data-line` attributes on blocks) for line threads; output sanitized with `nh3`. Diffs via `difflib` rendered server-side. |
|
||||||
|
|
@ -185,7 +186,7 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
|
||||||
- Pick email provider, configure anymail, verify magic link and invite emails.
|
- Pick email provider, configure anymail, verify magic link and invite emails.
|
||||||
- Moderation: deactivate user (sysadmin), delete comments (project owner), ghost user on account deletion.
|
- Moderation: deactivate user (sysadmin), delete comments (project owner), ghost user on account deletion.
|
||||||
- Playwright smoke tests with axe checks on the main pages in both color schemes.
|
- Playwright smoke tests with axe checks on the main pages in both color schemes.
|
||||||
- Rate limiting on login and comment endpoints (`django-ratelimit`), CSP header, security headers review.
|
- Rate limiting on comment endpoints (allauth already limits its own views; invites are throttled), CSP header, security headers review, second factor for `/admin/`.
|
||||||
- Backup restore drill repeated against the real volume.
|
- Backup restore drill repeated against the real volume.
|
||||||
- **Exit:** launch checklist (below) complete.
|
- **Exit:** launch checklist (below) complete.
|
||||||
|
|
||||||
|
|
@ -199,6 +200,8 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
|
||||||
- [ ] axe passes on feature, issue, project and org pages, light and dark
|
- [ ] axe passes on feature, issue, project and org pages, light and dark
|
||||||
- [ ] Keyboard-only walkthrough of the example flow
|
- [ ] Keyboard-only walkthrough of the example flow
|
||||||
- [ ] Privacy check: private project content not reachable via file URLs, feeds, or admin leaks
|
- [ ] Privacy check: private project content not reachable via file URLs, feeds, or admin leaks
|
||||||
|
- [ ] `EMAIL_BACKEND` set in production (prod settings refuse the console backend)
|
||||||
|
- [ ] Container confirmed running as the `app` user on the Fly volume
|
||||||
|
|
||||||
## 7. Hooks for V1.1
|
## 7. Hooks for V1.1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,10 @@ from .models import User
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class IntentUserAdmin(UserAdmin):
|
class IntentUserAdmin(UserAdmin):
|
||||||
search_fields = ["username", "email"]
|
search_fields = ["username", "email"]
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
# Only superusers may hand out staff or superuser status, or permissions.
|
||||||
|
fields = list(super().get_readonly_fields(request, obj))
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
fields += ["is_staff", "is_superuser", "groups", "user_permissions"]
|
||||||
|
return fields
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ from markdown_it import MarkdownIt
|
||||||
|
|
||||||
_ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()}
|
_ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()}
|
||||||
_ALLOWED_ATTRIBUTES["*"] = {"data-source-lines"}
|
_ALLOWED_ATTRIBUTES["*"] = {"data-source-lines"}
|
||||||
|
# No remote images: they would let an author log every reader's IP address.
|
||||||
|
# Uploaded images arrive in Phase 4 and will be served from our own storage.
|
||||||
|
_ALLOWED_TAGS = nh3.ALLOWED_TAGS - {"img"}
|
||||||
|
|
||||||
|
|
||||||
def _annotate(state):
|
def _annotate(state):
|
||||||
|
|
@ -33,4 +36,4 @@ _md = _build()
|
||||||
|
|
||||||
def render(text: str) -> SafeString:
|
def render(text: str) -> SafeString:
|
||||||
html = _md.render(text or "")
|
html = _md.render(text or "")
|
||||||
return mark_safe(nh3.clean(html, attributes=_ALLOWED_ATTRIBUTES))
|
return mark_safe(nh3.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRIBUTES))
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ class OrganizationAddForm(forms.ModelForm):
|
||||||
model = Organization
|
model = Organization
|
||||||
fields = ["name", "slug", "owner"]
|
fields = ["name", "slug", "owner"]
|
||||||
|
|
||||||
|
def clean_slug(self):
|
||||||
|
return self.cleaned_data["slug"].lower()
|
||||||
|
|
||||||
|
|
||||||
class OrgMembershipInline(admin.TabularInline):
|
class OrgMembershipInline(admin.TabularInline):
|
||||||
model = OrgMembership
|
model = OrgMembership
|
||||||
|
|
|
||||||
21
intent/orgs/migrations/0002_orginvite_claimed_by.py
Normal file
21
intent/orgs/migrations/0002_orginvite_claimed_by.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 5.2.18 on 2026-10-27 12:34
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orgs', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orginvite',
|
||||||
|
name='claimed_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -105,6 +105,15 @@ class OrgInvite(BaseModel):
|
||||||
)
|
)
|
||||||
expires_at = models.DateTimeField(default=_expiry)
|
expires_at = models.DateTimeField(default=_expiry)
|
||||||
responded_at = models.DateTimeField(null=True, blank=True)
|
responded_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
# Email invites: the first signed-in user to open the link claims the invite. The link
|
||||||
|
# stops working at that point, so a token copied from a log or a referrer is useless.
|
||||||
|
claimed_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
# Who actually accepted. For email invites, whoever held the link.
|
# Who actually accepted. For email invites, whoever held the link.
|
||||||
accepted_by = models.ForeignKey(
|
accepted_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Missing permissions raise PermissionDenied.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from allauth.account.models import EmailAddress
|
from allauth.account.models import EmailAddress
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
@ -15,6 +16,12 @@ from intent.access import org_access
|
||||||
from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role
|
from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role
|
||||||
|
|
||||||
LAST_OWNER_MESSAGE = "An organization needs at least one owner. Make someone else an owner first."
|
LAST_OWNER_MESSAGE = "An organization needs at least one owner. Make someone else an owner first."
|
||||||
|
TOO_MANY_INVITES_MESSAGE = "Too many invitations sent recently. Try again later."
|
||||||
|
|
||||||
|
# Each invite can send an email, so cap how many one person sends and how often one
|
||||||
|
# address is hit, with room for genuine use.
|
||||||
|
INVITES_PER_SENDER = (30, 60 * 60)
|
||||||
|
INVITES_PER_RECIPIENT = (3, 60 * 60)
|
||||||
|
|
||||||
|
|
||||||
def _require_owner(by, org):
|
def _require_owner(by, org):
|
||||||
|
|
@ -22,6 +29,20 @@ def _require_owner(by, org):
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
def _throttle(key: str, limit: int, seconds: int) -> None:
|
||||||
|
"""Count an event under `key`; raise once more than `limit` happen within `seconds`."""
|
||||||
|
key = f"intent:throttle:{key}"
|
||||||
|
if cache.add(key, 1, timeout=seconds):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
count = cache.incr(key)
|
||||||
|
except ValueError: # Expired between add and incr.
|
||||||
|
cache.add(key, 1, timeout=seconds)
|
||||||
|
return
|
||||||
|
if count > limit:
|
||||||
|
raise ValidationError(TOO_MANY_INVITES_MESSAGE)
|
||||||
|
|
||||||
|
|
||||||
def _is_last_owner(membership) -> bool:
|
def _is_last_owner(membership) -> bool:
|
||||||
return (
|
return (
|
||||||
membership.role == Role.OWNER
|
membership.role == Role.OWNER
|
||||||
|
|
@ -42,7 +63,7 @@ def create_org(*, slug, name, owner) -> Organization:
|
||||||
if owner.is_staff:
|
if owner.is_staff:
|
||||||
raise ValidationError("Administrator accounts cannot own organizations.")
|
raise ValidationError("Administrator accounts cannot own organizations.")
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
org = Organization.objects.create(slug=slug, name=name)
|
org = Organization.objects.create(slug=slug.lower(), name=name)
|
||||||
OrgMembership.objects.create(org=org, user=owner, role=Role.OWNER)
|
OrgMembership.objects.create(org=org, user=owner, role=Role.OWNER)
|
||||||
return org
|
return org
|
||||||
|
|
||||||
|
|
@ -56,6 +77,8 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
|
||||||
email = email.strip().lower()
|
email = email.strip().lower()
|
||||||
if bool(user) == bool(email):
|
if bool(user) == bool(email):
|
||||||
raise ValueError("Invite exactly one of user or email")
|
raise ValueError("Invite exactly one of user or email")
|
||||||
|
_throttle(f"invites-by:{by.pk}", *INVITES_PER_SENDER)
|
||||||
|
_throttle(f"invites-to:{org.pk}:{user.pk if user else email}", *INVITES_PER_RECIPIENT)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if user:
|
if user:
|
||||||
|
|
@ -79,10 +102,14 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
|
||||||
|
|
||||||
|
|
||||||
def open_invites_for(user):
|
def open_invites_for(user):
|
||||||
"""Pending, unexpired invites addressed to this user by username or verified email."""
|
"""Pending, unexpired invites addressed to this user: by username, verified email, or link."""
|
||||||
return (
|
return (
|
||||||
OrgInvite.objects.filter(status=InviteStatus.PENDING, expires_at__gt=timezone.now())
|
OrgInvite.objects.filter(status=InviteStatus.PENDING, expires_at__gt=timezone.now())
|
||||||
.filter(Q(user=user) | Q(email__in=_verified_emails(user), email__gt=""))
|
.filter(
|
||||||
|
Q(user=user)
|
||||||
|
| Q(claimed_by=user)
|
||||||
|
| Q(email__in=_verified_emails(user), email__gt="", claimed_by__isnull=True)
|
||||||
|
)
|
||||||
.select_related("org", "invited_by")
|
.select_related("org", "invited_by")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -90,9 +117,31 @@ def open_invites_for(user):
|
||||||
def is_addressed_to(invite, user) -> bool:
|
def is_addressed_to(invite, user) -> bool:
|
||||||
if invite.user_id:
|
if invite.user_id:
|
||||||
return invite.user_id == user.pk
|
return invite.user_id == user.pk
|
||||||
|
if invite.claimed_by_id:
|
||||||
|
return invite.claimed_by_id == user.pk
|
||||||
return invite.email in _verified_emails(user)
|
return invite.email in _verified_emails(user)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_invite(invite, user) -> None:
|
||||||
|
"""Bind an email invite to the signed-in user who opened its link, and retire the link.
|
||||||
|
|
||||||
|
Whoever holds the link may claim, whichever account they sign in with. Only one account
|
||||||
|
can: a second holder of the same link sees nothing.
|
||||||
|
"""
|
||||||
|
with transaction.atomic():
|
||||||
|
invite.refresh_from_db()
|
||||||
|
if invite.claimed_by_id and invite.claimed_by_id != user.pk:
|
||||||
|
raise PermissionDenied
|
||||||
|
if not invite.is_open:
|
||||||
|
raise ValidationError("This invitation is no longer valid.")
|
||||||
|
if user.is_staff:
|
||||||
|
raise ValidationError("Administrator accounts cannot join organizations.")
|
||||||
|
if invite.claimed_by_id is None:
|
||||||
|
invite.claimed_by = user
|
||||||
|
invite.set_new_token() # The old link stops working; the new token is discarded.
|
||||||
|
invite.save(update_fields=["claimed_by", "token_hash", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
def accept_invite(invite, user) -> OrgMembership:
|
def accept_invite(invite, user) -> OrgMembership:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
invite.refresh_from_db()
|
invite.refresh_from_db()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ urlpatterns = [
|
||||||
invite_urlpatterns = (
|
invite_urlpatterns = (
|
||||||
[
|
[
|
||||||
path("", views.my_invites, name="list"),
|
path("", views.my_invites, name="list"),
|
||||||
|
path("<str:invite_id>/", views.invite_show, name="show"),
|
||||||
path("<str:invite_id>/respond/", views.invite_respond, name="respond"),
|
path("<str:invite_id>/respond/", views.invite_respond, name="respond"),
|
||||||
path("t/<str:token>/", views.invite_by_token, name="by_token"),
|
path("t/<str:token>/", views.invite_by_token, name="by_token"),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
@ -145,21 +146,37 @@ def _respond(request, invite):
|
||||||
return redirect("invites:list")
|
return redirect("invites:list")
|
||||||
|
|
||||||
|
|
||||||
|
def _addressed_invite(request, invite_id):
|
||||||
|
invite = get_object_or_404(OrgInvite.objects.select_related("org", "invited_by"), pk=invite_id)
|
||||||
|
if not services.is_addressed_to(invite, request.user):
|
||||||
|
raise PermissionDenied
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def invite_show(request, invite_id):
|
||||||
|
return render(request, "orgs/invite.html", {"invite": _addressed_invite(request, invite_id)})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def invite_respond(request, invite_id):
|
def invite_respond(request, invite_id):
|
||||||
invite = get_object_or_404(OrgInvite.objects.select_related("org"), pk=invite_id)
|
return _respond(request, _addressed_invite(request, invite_id))
|
||||||
if not services.is_addressed_to(invite, request.user):
|
|
||||||
raise PermissionDenied
|
|
||||||
return _respond(request, invite)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def invite_by_token(request, token):
|
def invite_by_token(request, token):
|
||||||
"""Email invites: whoever holds the link may accept, whichever account they sign in with."""
|
"""Email invite links. The first signed-in visitor claims the invite and the link dies.
|
||||||
invite = get_object_or_404(
|
|
||||||
OrgInvite.objects.select_related("org", "invited_by"), token_hash=hash_token(token)
|
Nothing is shown at this URL: the token appears in request logs, so it must be worthless
|
||||||
)
|
the moment it has been used once.
|
||||||
if request.method == "POST":
|
"""
|
||||||
return _respond(request, invite)
|
invite = get_object_or_404(OrgInvite, token_hash=hash_token(token))
|
||||||
return render(request, "orgs/invite.html", {"invite": invite})
|
try:
|
||||||
|
services.claim_invite(invite, request.user)
|
||||||
|
except PermissionDenied:
|
||||||
|
raise Http404 from None
|
||||||
|
except ValidationError as exc:
|
||||||
|
_error(request, exc)
|
||||||
|
return redirect("invites:list")
|
||||||
|
return redirect("invites:show", invite_id=invite.pk)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from functools import wraps
|
||||||
from django.contrib.auth.views import redirect_to_login
|
from django.contrib.auth.views import redirect_to_login
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
|
|
||||||
from intent.access import project_access
|
from intent.access import project_access
|
||||||
|
|
||||||
|
|
@ -14,20 +13,23 @@ def project_view(require="can_view"):
|
||||||
"""Load the project from the URL, check access, and call view(request, project, access, ...).
|
"""Load the project from the URL, check access, and call view(request, project, access, ...).
|
||||||
|
|
||||||
Projects a user can't see are 404s, so private projects don't reveal they exist.
|
Projects a user can't see are 404s, so private projects don't reveal they exist.
|
||||||
Signed-out visitors are sent to sign in instead.
|
Signed-out visitors are sent to sign in instead, whether or not the project exists,
|
||||||
|
so the redirect doesn't reveal it either.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(view):
|
def decorator(view):
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
def wrapper(request, org_slug, project_slug, **kwargs):
|
def wrapper(request, org_slug, project_slug, **kwargs):
|
||||||
project = get_object_or_404(
|
project = (
|
||||||
Project.objects.select_related("org"), org__slug=org_slug, slug=project_slug
|
Project.objects.select_related("org")
|
||||||
|
.filter(org__slug=org_slug, slug=project_slug)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
access = project_access(request.user, project)
|
access = project_access(request.user, project) if project else None
|
||||||
if not access.can_view or not getattr(access, require):
|
if access is None or not access.can_view or not getattr(access, require):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect_to_login(request.get_full_path())
|
return redirect_to_login(request.get_full_path())
|
||||||
if not access.can_view:
|
if access is None or not access.can_view:
|
||||||
raise Http404
|
raise Http404
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
return view(request, project, access, **kwargs)
|
return view(request, project, access, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ class ProjectCreateForm(forms.ModelForm):
|
||||||
}
|
}
|
||||||
widgets = {"visibility": forms.RadioSelect}
|
widgets = {"visibility": forms.RadioSelect}
|
||||||
|
|
||||||
|
def clean_slug(self):
|
||||||
|
return self.cleaned_data["slug"].lower()
|
||||||
|
|
||||||
def clean_feature_prefix(self):
|
def clean_feature_prefix(self):
|
||||||
return self.cleaned_data["feature_prefix"].upper()
|
return self.cleaned_data["feature_prefix"].upper()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ def create_project(org, by, *, slug, name, feature_prefix, visibility, open_cont
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
project = Project(
|
project = Project(
|
||||||
org=org,
|
org=org,
|
||||||
slug=slug,
|
# Slugs are lowercased so two projects can't differ only by case in the URL.
|
||||||
|
slug=slug.lower(),
|
||||||
name=name,
|
name=name,
|
||||||
feature_prefix=feature_prefix.upper(),
|
feature_prefix=feature_prefix.upper(),
|
||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
{% if invite.invited_by %}{{ invite.invited_by.username }} invited you{% else %}You're invited{% endif %}
|
{% if invite.invited_by %}{{ invite.invited_by.username }} invited you{% else %}You're invited{% endif %}
|
||||||
to join as {{ invite.get_role_display|lower }}. You'll join as <strong>{{ user.username }}</strong>.
|
to join as {{ invite.get_role_display|lower }}. You'll join as <strong>{{ user.username }}</strong>.
|
||||||
</p>
|
</p>
|
||||||
<form method="post">
|
<form method="post" action="{% url 'invites:respond' invite_id=invite.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" name="action" value="accept">Accept</button>
|
<button type="submit" name="action" value="accept">Accept</button>
|
||||||
<button type="submit" name="action" value="decline">Decline</button>
|
<button type="submit" name="action" value="decline">Decline</button>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import re
|
||||||
import pytest
|
import pytest
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import NoReverseMatch, reverse
|
||||||
|
|
||||||
from intent.accounts.validators import username_validators
|
from intent.accounts.validators import username_validators
|
||||||
|
|
||||||
|
|
@ -50,3 +50,28 @@ def test_invalid_usernames(value):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
for validator in username_validators:
|
for validator in username_validators:
|
||||||
validator(value)
|
validator(value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name", ["account_set_password", "account_change_password", "account_reset_password"]
|
||||||
|
)
|
||||||
|
def test_password_urls_do_not_exist(name):
|
||||||
|
with pytest.raises(NoReverseMatch):
|
||||||
|
reverse(name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_in_pages_without_passwords_render(client, owner):
|
||||||
|
client.force_login(owner)
|
||||||
|
assert client.get(reverse("account_email")).status_code == 200
|
||||||
|
assert client.get("/accounts/password/set/").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_staff_cannot_grant_staff_without_superuser(client, make_user):
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
|
||||||
|
staff = make_user("stan", is_staff=True)
|
||||||
|
staff.user_permissions.add(*Permission.objects.filter(content_type__app_label="accounts"))
|
||||||
|
client.force_login(staff)
|
||||||
|
target = make_user("tina")
|
||||||
|
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
|
||||||
|
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ def test_raw_html_and_script_links_are_neutralised():
|
||||||
assert "onerror" not in html.replace("onerror=y>", "")
|
assert "onerror" not in html.replace("onerror=y>", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_images_are_dropped():
|
||||||
|
assert "<img" not in render("")
|
||||||
|
|
||||||
|
|
||||||
def test_tables_and_strikethrough():
|
def test_tables_and_strikethrough():
|
||||||
html = render("| a |\n|---|\n| 1 |\n\n~~old~~")
|
html = render("| a |\n|---|\n| 1 |\n\n~~old~~")
|
||||||
assert "<table" in html and "<s>old</s>" in html
|
assert "<table" in html and "<s>old</s>" in html
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from intent.orgs.models import InviteStatus, OrgInvite, Role
|
from intent.orgs.models import InviteStatus, OrgInvite, Role
|
||||||
|
|
@ -68,13 +70,40 @@ def test_email_invite_link_flow(client, owner, make_user, org):
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response["Location"].startswith(reverse("account_login"))
|
assert response["Location"].startswith(reverse("account_login"))
|
||||||
|
|
||||||
# Bearer link: any signed-in account can accept, whatever its email.
|
# Bearer link: any signed-in account can claim it, whatever its email.
|
||||||
joiner = make_user("june", email="june@elsewhere.example")
|
joiner = make_user("june", email="june@elsewhere.example")
|
||||||
client.force_login(joiner)
|
client.force_login(joiner)
|
||||||
assert client.get(path).status_code == 200
|
invite = OrgInvite.objects.get(email="new@example.com")
|
||||||
client.post(path, {"action": "accept"})
|
show = reverse("invites:show", kwargs={"invite_id": invite.pk})
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code == 302 and response["Location"] == show
|
||||||
|
assert client.get(show).status_code == 200
|
||||||
|
assert invite in list(client.get(reverse("invites:list")).context["invites"])
|
||||||
|
|
||||||
|
# Once claimed, the link is dead for everyone else, even someone with the invited email.
|
||||||
|
other = make_user("newbie", email="new@example.com")
|
||||||
|
client.force_login(other)
|
||||||
|
assert client.get(path).status_code == 404
|
||||||
|
assert client.get(show).status_code == 403
|
||||||
|
assert client.get(reverse("invites:list")).context["invites"].count() == 0
|
||||||
|
|
||||||
|
client.force_login(joiner)
|
||||||
|
assert client.get(path).status_code == 404 # Token was rotated on claim.
|
||||||
|
client.post(reverse("invites:respond", kwargs={"invite_id": invite.pk}), {"action": "accept"})
|
||||||
assert org.memberships.filter(user=joiner).exists()
|
assert org.memberships.filter(user=joiner).exists()
|
||||||
assert OrgInvite.objects.get(email="new@example.com").status == InviteStatus.ACCEPTED
|
invite.refresh_from_db()
|
||||||
|
assert invite.status == InviteStatus.ACCEPTED and invite.accepted_by == joiner
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_sending_is_throttled(owner, org):
|
||||||
|
from intent.orgs import services
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
services.invite(org, owner, email="same@example.com")
|
||||||
|
with pytest.raises(ValidationError, match="Too many"):
|
||||||
|
services.invite(org, owner, email="same@example.com")
|
||||||
|
# A different address still works; the per-sender cap is higher.
|
||||||
|
services.invite(org, owner, email="other@example.com")
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_token_is_404(client, make_user):
|
def test_invalid_token_is_404(client, make_user):
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,14 @@ def test_reserved_slug_rejected(owner, org):
|
||||||
|
|
||||||
|
|
||||||
def test_private_project_hidden_from_outsiders(client, make_user, project):
|
def test_private_project_hidden_from_outsiders(client, make_user, project):
|
||||||
response = client.get(url("home", project))
|
missing = f"/{project.org.slug}/no-such-project/"
|
||||||
assert response.status_code == 302 and "/accounts/login/" in response["Location"]
|
# Signed-out visitors can't tell a private project from a missing one: both ask to sign in.
|
||||||
|
for path in [url("home", project), missing]:
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code == 302 and "/accounts/login/" in response["Location"], path
|
||||||
client.force_login(make_user())
|
client.force_login(make_user())
|
||||||
assert client.get(url("home", project)).status_code == 404
|
assert client.get(url("home", project)).status_code == 404
|
||||||
|
assert client.get(missing).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_public_project_visible_to_anonymous(client, project):
|
def test_public_project_visible_to_anonymous(client, project):
|
||||||
|
|
@ -125,3 +129,12 @@ def test_org_page_lists_visible_projects(client, owner, collaborator, org, proje
|
||||||
assert link in client.get(org_page).content.decode()
|
assert link in client.get(org_page).content.decode()
|
||||||
client.force_login(collaborator)
|
client.force_login(collaborator)
|
||||||
assert link not in client.get(org_page).content.decode()
|
assert link not in client.get(org_page).content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugs_are_lowercased(client, owner, org):
|
||||||
|
client.force_login(owner)
|
||||||
|
client.post(
|
||||||
|
reverse("project_create", kwargs={"org_slug": org.slug}),
|
||||||
|
{"name": "Docs", "slug": "Docs", "feature_prefix": "doc", "visibility": "public"},
|
||||||
|
)
|
||||||
|
assert Project.objects.filter(slug="docs").exists()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue