923 lines
32 KiB
Python
923 lines
32 KiB
Python
"""
|
|
Tests for Org Social Host application.
|
|
Following the Given/When/Then pattern from org-social-relay.
|
|
"""
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
from django.test import TestCase
|
|
from rest_framework import status
|
|
from rest_framework.test import APIClient
|
|
|
|
from .models import HostedFile
|
|
from .utils import (
|
|
build_vfile_url,
|
|
extract_nick_from_content,
|
|
generate_vfile_token,
|
|
validate_nickname,
|
|
)
|
|
|
|
|
|
class RootViewTest(TestCase):
|
|
"""Test cases for the root endpoint."""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.root_url = "/"
|
|
|
|
def test_root_endpoint_success(self):
|
|
"""Test GET / returns success response with HATEOAS links."""
|
|
# Given: The root endpoint
|
|
|
|
# When: We request the root endpoint
|
|
response = self.client.get(self.root_url)
|
|
|
|
# Then: We should get success response
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.json()["type"], "Success")
|
|
self.assertEqual(response.json()["errors"], [])
|
|
|
|
# Then: Should have data with name and description
|
|
data = response.json()["data"]
|
|
self.assertIn("name", data)
|
|
self.assertIn("description", data)
|
|
self.assertEqual(data["name"], "Org Social Host")
|
|
|
|
def test_root_endpoint_hateoas_links(self):
|
|
"""Test GET / returns all expected HATEOAS links."""
|
|
# Given: The root endpoint
|
|
|
|
# When: We request the root endpoint
|
|
response = self.client.get(self.root_url)
|
|
|
|
# Then: Should have _links with all endpoints
|
|
links = response.json()["_links"]
|
|
expected_links = [
|
|
"self",
|
|
"signup",
|
|
"upload",
|
|
"delete",
|
|
"redirect",
|
|
"remove-redirect",
|
|
]
|
|
|
|
for link_name in expected_links:
|
|
self.assertIn(link_name, links, f"Missing link: {link_name}")
|
|
|
|
def test_root_endpoint_response_format_compliance(self):
|
|
"""Test root endpoint response format compliance."""
|
|
# Given: The root endpoint
|
|
|
|
# When: We request the root endpoint
|
|
response = self.client.get(self.root_url)
|
|
|
|
# Then: Response should match expected format
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
response_data = response.json()
|
|
self.assertIn("type", response_data)
|
|
self.assertIn("errors", response_data)
|
|
self.assertIn("data", response_data)
|
|
self.assertIn("_links", response_data)
|
|
self.assertIsInstance(response_data["errors"], list)
|
|
self.assertIsInstance(response_data["data"], dict)
|
|
self.assertIsInstance(response_data["_links"], dict)
|
|
|
|
|
|
class SignupViewTest(TestCase):
|
|
"""Test cases for the signup endpoint."""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.signup_url = "/signup"
|
|
|
|
def test_signup_success(self):
|
|
"""Test POST /signup with valid nickname creates account."""
|
|
# Given: A valid nickname that doesn't exist
|
|
nickname = "test_user"
|
|
|
|
# When: We signup with the nickname
|
|
response = self.client.post(
|
|
self.signup_url,
|
|
{"nick": nickname},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get success response
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.json()["type"], "Success")
|
|
self.assertEqual(response.json()["errors"], [])
|
|
|
|
# Then: Response should contain vfile and public-url
|
|
data = response.json()["data"]
|
|
self.assertIn("vfile", data)
|
|
self.assertIn("public-url", data)
|
|
|
|
# Then: vfile should be a valid URL with required params
|
|
vfile = data["vfile"]
|
|
self.assertIn("token=", vfile)
|
|
self.assertIn("ts=", vfile)
|
|
self.assertIn("sig=", vfile)
|
|
|
|
# Then: public-url should match expected format
|
|
public_url = data["public-url"]
|
|
self.assertIn(f"/{nickname}/social.org", public_url)
|
|
|
|
# Then: HostedFile should be created in database
|
|
hosted_file = HostedFile.objects.get(nickname=nickname)
|
|
self.assertEqual(hosted_file.nickname, nickname)
|
|
self.assertIsNotNone(hosted_file.vfile_token)
|
|
|
|
def test_signup_nickname_already_taken(self):
|
|
"""Test POST /signup with existing nickname returns error."""
|
|
# Given: A nickname that already exists
|
|
nickname = "existing_user"
|
|
HostedFile.objects.create(
|
|
nickname=nickname,
|
|
vfile_token="token123",
|
|
vfile_timestamp=1234567890,
|
|
vfile_signature="sig123",
|
|
file_content="",
|
|
)
|
|
|
|
# When: We try to signup with the same nickname
|
|
response = self.client.post(
|
|
self.signup_url,
|
|
{"nick": nickname},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertIn("already taken", response.json()["errors"][0].lower())
|
|
|
|
def test_signup_invalid_nickname_too_short(self):
|
|
"""Test POST /signup with too short nickname returns error."""
|
|
# Given: A nickname that is too short
|
|
nickname = "ab"
|
|
|
|
# When: We try to signup
|
|
response = self.client.post(
|
|
self.signup_url,
|
|
{"nick": nickname},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertIn("at least 3 characters", response.json()["errors"][0].lower())
|
|
|
|
def test_signup_invalid_nickname_special_chars(self):
|
|
"""Test POST /signup with invalid characters returns error."""
|
|
# Given: A nickname with invalid characters
|
|
nickname = "user@name"
|
|
|
|
# When: We try to signup
|
|
response = self.client.post(
|
|
self.signup_url,
|
|
{"nick": nickname},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertTrue(
|
|
any("letters" in err.lower() or "alphanumeric" in err.lower()
|
|
for err in response.json()["errors"])
|
|
)
|
|
|
|
def test_signup_missing_nickname(self):
|
|
"""Test POST /signup without nickname returns error."""
|
|
# Given: No nickname provided
|
|
|
|
# When: We try to signup without nickname
|
|
response = self.client.post(
|
|
self.signup_url,
|
|
{},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
|
|
|
|
class UploadViewTest(TestCase):
|
|
"""Test cases for the upload endpoint."""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.upload_url = "/upload"
|
|
|
|
# Create a test user
|
|
self.nickname = "test_user"
|
|
token_data = generate_vfile_token(self.nickname)
|
|
self.vfile = build_vfile_url(
|
|
token_data["token"],
|
|
token_data["timestamp"],
|
|
token_data["signature"],
|
|
)
|
|
|
|
# Create hosted file
|
|
self.hosted_file = HostedFile.objects.create(
|
|
nickname=self.nickname,
|
|
vfile_token=token_data["token"],
|
|
vfile_timestamp=token_data["timestamp"],
|
|
vfile_signature=token_data["signature"],
|
|
file_content="",
|
|
)
|
|
|
|
def test_upload_success(self):
|
|
"""Test POST /upload with valid vfile uploads file."""
|
|
# Given: A valid vfile and file content
|
|
file_content = b"#+TITLE: Test\n\n* Posts\n** Test post\n"
|
|
|
|
# Create a file-like object
|
|
from io import BytesIO
|
|
file = BytesIO(file_content)
|
|
file.name = "social.org"
|
|
|
|
# When: We upload the file
|
|
response = self.client.post(
|
|
self.upload_url,
|
|
{
|
|
"vfile": self.vfile,
|
|
"file": file,
|
|
},
|
|
format="multipart",
|
|
)
|
|
|
|
# Then: We should get success response
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.json()["type"], "Success")
|
|
self.assertEqual(response.json()["errors"], [])
|
|
|
|
# Then: Response should contain confirmation
|
|
data = response.json()["data"]
|
|
self.assertIn("message", data)
|
|
self.assertIn("public-url", data)
|
|
|
|
# Then: Content should be saved in database
|
|
self.hosted_file.refresh_from_db()
|
|
self.assertEqual(self.hosted_file.file_content, file_content.decode("utf-8"))
|
|
|
|
def test_upload_invalid_vfile(self):
|
|
"""Test POST /upload with invalid vfile returns error."""
|
|
# Given: An invalid vfile
|
|
invalid_vfile = "http://localhost/vfile?token=invalid&ts=123&sig=bad"
|
|
|
|
file_content = b"#+TITLE: Test\n"
|
|
from io import BytesIO
|
|
file = BytesIO(file_content)
|
|
file.name = "social.org"
|
|
|
|
# When: We try to upload with invalid vfile
|
|
response = self.client.post(
|
|
self.upload_url,
|
|
{
|
|
"vfile": invalid_vfile,
|
|
"file": file,
|
|
},
|
|
format="multipart",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertIn("invalid", response.json()["errors"][0].lower())
|
|
|
|
def test_upload_file_too_large(self):
|
|
"""Test POST /upload with file exceeding size limit returns error."""
|
|
# Given: A file that exceeds MAX_FILE_SIZE
|
|
large_content = b"X" * (settings.MAX_FILE_SIZE + 1)
|
|
|
|
from io import BytesIO
|
|
file = BytesIO(large_content)
|
|
file.name = "social.org"
|
|
|
|
# When: We try to upload the large file
|
|
response = self.client.post(
|
|
self.upload_url,
|
|
{
|
|
"vfile": self.vfile,
|
|
"file": file,
|
|
},
|
|
format="multipart",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertIn("too large", response.json()["errors"][0].lower())
|
|
|
|
def test_upload_missing_file(self):
|
|
"""Test POST /upload without file returns error."""
|
|
# Given: No file provided
|
|
|
|
# When: We try to upload without file
|
|
response = self.client.post(
|
|
self.upload_url,
|
|
{"vfile": self.vfile},
|
|
format="multipart",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
|
|
def test_upload_rejects_nick_matching_other_nickname(self):
|
|
"""Test POST /upload rejects file whose #+NICK matches another account's nickname."""
|
|
# Given: Another registered account
|
|
other_nickname = "alice"
|
|
other_token = generate_vfile_token(other_nickname)
|
|
HostedFile.objects.create(
|
|
nickname=other_nickname,
|
|
vfile_token=other_token["token"],
|
|
vfile_timestamp=other_token["timestamp"],
|
|
vfile_signature=other_token["signature"],
|
|
file_content="",
|
|
)
|
|
|
|
# Given: An upload that declares the other account's nickname as #+NICK
|
|
file_content = f"#+TITLE: Stolen\n#+NICK: {other_nickname}\n\n* Posts\n".encode()
|
|
from io import BytesIO
|
|
file = BytesIO(file_content)
|
|
file.name = "social.org"
|
|
|
|
# When: We try to upload
|
|
response = self.client.post(
|
|
self.upload_url,
|
|
{"vfile": self.vfile, "file": file},
|
|
format="multipart",
|
|
)
|
|
|
|
# Then: We should get a 409 Conflict
|
|
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertIn("already in use", response.json()["errors"][0].lower())
|
|
|
|
# Then: Our file content must remain unchanged
|
|
self.hosted_file.refresh_from_db()
|
|
self.assertEqual(self.hosted_file.file_content, "")
|
|
|
|
def test_upload_rejects_nick_present_in_other_file_content(self):
|
|
"""Test POST /upload rejects file whose #+NICK matches another account's #+NICK."""
|
|
# Given: Another registered account whose stored file declares #+NICK: bob
|
|
other_nickname = "other_user"
|
|
other_token = generate_vfile_token(other_nickname)
|
|
HostedFile.objects.create(
|
|
nickname=other_nickname,
|
|
vfile_token=other_token["token"],
|
|
vfile_timestamp=other_token["timestamp"],
|
|
vfile_signature=other_token["signature"],
|
|
file_content="#+TITLE: Bob\n#+NICK: bob\n\n* Posts\n",
|
|
)
|
|
|
|
# Given: An upload declaring the same #+NICK
|
|
file_content = b"#+TITLE: Impostor\n#+NICK: bob\n\n* Posts\n"
|
|
from io import BytesIO
|
|
file = BytesIO(file_content)
|
|
file.name = "social.org"
|
|
|
|
# When: We try to upload
|
|
response = self.client.post(
|
|
self.upload_url,
|
|
{"vfile": self.vfile, "file": file},
|
|
format="multipart",
|
|
)
|
|
|
|
# Then: We should get a 409 Conflict
|
|
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertIn("bob", response.json()["errors"][0].lower())
|
|
|
|
def test_upload_allows_own_nick(self):
|
|
"""Test POST /upload accepts a file whose #+NICK matches the uploader's own nickname."""
|
|
# Given: A file content declaring the uploader's own nickname
|
|
file_content = f"#+TITLE: Mine\n#+NICK: {self.nickname}\n\n* Posts\n".encode()
|
|
from io import BytesIO
|
|
file = BytesIO(file_content)
|
|
file.name = "social.org"
|
|
|
|
# When: We upload
|
|
response = self.client.post(
|
|
self.upload_url,
|
|
{"vfile": self.vfile, "file": file},
|
|
format="multipart",
|
|
)
|
|
|
|
# Then: Upload succeeds
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.hosted_file.refresh_from_db()
|
|
self.assertEqual(self.hosted_file.file_content, file_content.decode("utf-8"))
|
|
|
|
|
|
class DeleteViewTest(TestCase):
|
|
"""Test cases for the delete endpoint."""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.delete_url = "/delete"
|
|
|
|
# Create a test user
|
|
self.nickname = "test_user"
|
|
token_data = generate_vfile_token(self.nickname)
|
|
self.vfile = build_vfile_url(
|
|
token_data["token"],
|
|
token_data["timestamp"],
|
|
token_data["signature"],
|
|
)
|
|
|
|
# Create hosted file
|
|
self.hosted_file = HostedFile.objects.create(
|
|
nickname=self.nickname,
|
|
vfile_token=token_data["token"],
|
|
vfile_timestamp=token_data["timestamp"],
|
|
vfile_signature=token_data["signature"],
|
|
file_content="",
|
|
)
|
|
|
|
def test_delete_success(self):
|
|
"""Test POST /delete with valid vfile deletes account."""
|
|
# Given: A valid vfile for existing account
|
|
|
|
# When: We delete the account
|
|
response = self.client.post(
|
|
self.delete_url,
|
|
{"vfile": self.vfile},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get success response
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.json()["type"], "Success")
|
|
self.assertEqual(response.json()["errors"], [])
|
|
|
|
# Then: HostedFile should be deleted from database
|
|
self.assertFalse(
|
|
HostedFile.objects.filter(nickname=self.nickname).exists()
|
|
)
|
|
|
|
def test_delete_invalid_vfile(self):
|
|
"""Test POST /delete with invalid vfile returns error."""
|
|
# Given: An invalid vfile
|
|
invalid_vfile = "http://localhost/vfile?token=invalid&ts=123&sig=bad"
|
|
|
|
# When: We try to delete with invalid vfile
|
|
response = self.client.post(
|
|
self.delete_url,
|
|
{"vfile": invalid_vfile},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertIn("invalid", response.json()["errors"][0].lower())
|
|
|
|
# Then: HostedFile should still exist
|
|
self.assertTrue(
|
|
HostedFile.objects.filter(nickname=self.nickname).exists()
|
|
)
|
|
|
|
def test_delete_nonexistent_account(self):
|
|
"""Test POST /delete for non-existent account returns error."""
|
|
# Given: A valid vfile for non-existent account
|
|
token_data = generate_vfile_token("nonexistent")
|
|
vfile = build_vfile_url(
|
|
token_data["token"],
|
|
token_data["timestamp"],
|
|
token_data["signature"],
|
|
)
|
|
|
|
# When: We try to delete non-existent account
|
|
response = self.client.post(
|
|
self.delete_url,
|
|
{"vfile": vfile},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
|
|
|
|
class RedirectViewTest(TestCase):
|
|
"""Test cases for the redirect endpoint."""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.redirect_url = "/redirect"
|
|
|
|
# Create a test user
|
|
self.nickname = "test_user"
|
|
token_data = generate_vfile_token(self.nickname)
|
|
self.vfile = build_vfile_url(
|
|
token_data["token"],
|
|
token_data["timestamp"],
|
|
token_data["signature"],
|
|
)
|
|
|
|
# Create hosted file
|
|
self.hosted_file = HostedFile.objects.create(
|
|
nickname=self.nickname,
|
|
vfile_token=token_data["token"],
|
|
vfile_timestamp=token_data["timestamp"],
|
|
vfile_signature=token_data["signature"],
|
|
file_content="",
|
|
)
|
|
|
|
def test_redirect_success(self):
|
|
"""Test POST /redirect with valid vfile sets redirect."""
|
|
# Given: A valid vfile and new URL
|
|
new_url = "https://my-domain.org/social.org"
|
|
|
|
# When: We set up a redirect
|
|
response = self.client.post(
|
|
self.redirect_url,
|
|
{
|
|
"vfile": self.vfile,
|
|
"new-url": new_url,
|
|
},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get success response
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.json()["type"], "Success")
|
|
self.assertEqual(response.json()["errors"], [])
|
|
|
|
# Then: Response should contain redirect URL
|
|
data = response.json()["data"]
|
|
self.assertIn("redirect-url", data)
|
|
self.assertEqual(data["redirect-url"], new_url)
|
|
|
|
# Then: HostedFile should have redirect_url set
|
|
self.hosted_file.refresh_from_db()
|
|
self.assertEqual(self.hosted_file.redirect_url, new_url)
|
|
|
|
def test_redirect_invalid_url(self):
|
|
"""Test POST /redirect with invalid URL returns error."""
|
|
# Given: An invalid URL
|
|
invalid_url = "not-a-valid-url"
|
|
|
|
# When: We try to set redirect with invalid URL
|
|
response = self.client.post(
|
|
self.redirect_url,
|
|
{
|
|
"vfile": self.vfile,
|
|
"new-url": invalid_url,
|
|
},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
|
|
# Then: HostedFile should not have redirect_url set
|
|
self.hosted_file.refresh_from_db()
|
|
self.assertIsNone(self.hosted_file.redirect_url)
|
|
|
|
|
|
class RemoveRedirectViewTest(TestCase):
|
|
"""Test cases for the remove-redirect endpoint."""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
self.remove_redirect_url = "/remove-redirect"
|
|
|
|
# Create a test user with redirect
|
|
self.nickname = "test_user"
|
|
token_data = generate_vfile_token(self.nickname)
|
|
self.vfile = build_vfile_url(
|
|
token_data["token"],
|
|
token_data["timestamp"],
|
|
token_data["signature"],
|
|
)
|
|
|
|
# Create hosted file with redirect
|
|
self.hosted_file = HostedFile.objects.create(
|
|
nickname=self.nickname,
|
|
vfile_token=token_data["token"],
|
|
vfile_timestamp=token_data["timestamp"],
|
|
vfile_signature=token_data["signature"],
|
|
file_content="",
|
|
redirect_url="https://other-domain.org/social.org",
|
|
)
|
|
|
|
def test_remove_redirect_success(self):
|
|
"""Test POST /remove-redirect with valid vfile removes redirect."""
|
|
# Given: A valid vfile for account with redirect
|
|
|
|
# When: We remove the redirect
|
|
response = self.client.post(
|
|
self.remove_redirect_url,
|
|
{"vfile": self.vfile},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get success response
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.json()["type"], "Success")
|
|
self.assertEqual(response.json()["errors"], [])
|
|
|
|
# Then: HostedFile should not have redirect_url
|
|
self.hosted_file.refresh_from_db()
|
|
self.assertIsNone(self.hosted_file.redirect_url)
|
|
|
|
def test_remove_redirect_no_redirect_configured(self):
|
|
"""Test POST /remove-redirect when no redirect exists returns error."""
|
|
# Given: An account without redirect
|
|
self.hosted_file.redirect_url = None
|
|
self.hosted_file.save()
|
|
|
|
# When: We try to remove redirect
|
|
response = self.client.post(
|
|
self.remove_redirect_url,
|
|
{"vfile": self.vfile},
|
|
format="json",
|
|
)
|
|
|
|
# Then: We should get error response
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertEqual(response.json()["type"], "Error")
|
|
self.assertIn("no redirect", response.json()["errors"][0].lower())
|
|
|
|
|
|
class ServeFileViewTest(TestCase):
|
|
"""Test cases for the serve file endpoint."""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
|
|
# Create a test user
|
|
self.nickname = "test_user"
|
|
token_data = generate_vfile_token(self.nickname)
|
|
|
|
# Create hosted file with content
|
|
self.file_content = "#+TITLE: Test\n\n* Posts\n** Test post\n"
|
|
self.hosted_file = HostedFile.objects.create(
|
|
nickname=self.nickname,
|
|
vfile_token=token_data["token"],
|
|
vfile_timestamp=token_data["timestamp"],
|
|
vfile_signature=token_data["signature"],
|
|
file_content=self.file_content,
|
|
)
|
|
|
|
def test_serve_file_success(self):
|
|
"""Test GET /<nickname>/social.org serves file."""
|
|
# Given: A file exists for the nickname
|
|
serve_url = f"/{self.nickname}/social.org"
|
|
|
|
# When: We request the file
|
|
response = self.client.get(serve_url)
|
|
|
|
# Then: We should get the file content
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.content.decode("utf-8"), self.file_content)
|
|
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
|
|
|
def test_serve_file_not_found(self):
|
|
"""Test GET /<nickname>/social.org for non-existent file returns 404."""
|
|
# Given: A nickname that doesn't exist
|
|
serve_url = "/nonexistent/social.org"
|
|
|
|
# When: We request the file
|
|
response = self.client.get(serve_url)
|
|
|
|
# Then: We should get 404
|
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
|
|
|
def test_serve_file_with_redirect(self):
|
|
"""Test GET /<nickname>/social.org with redirect returns 301."""
|
|
# Given: A file with redirect configured
|
|
redirect_url = "https://new-domain.org/social.org"
|
|
self.hosted_file.redirect_url = redirect_url
|
|
self.hosted_file.save()
|
|
|
|
serve_url = f"/{self.nickname}/social.org"
|
|
|
|
# When: We request the file
|
|
response = self.client.get(serve_url, follow=False)
|
|
|
|
# Then: We should get 301 redirect
|
|
self.assertEqual(response.status_code, status.HTTP_301_MOVED_PERMANENTLY)
|
|
self.assertEqual(response["Location"], redirect_url)
|
|
|
|
|
|
class UtilsTest(TestCase):
|
|
"""Test cases for utility functions."""
|
|
|
|
def test_generate_vfile_token(self):
|
|
"""Test generate_vfile_token creates valid token."""
|
|
# Given: A nickname
|
|
nickname = "test_user"
|
|
|
|
# When: We generate a token
|
|
token_data = generate_vfile_token(nickname)
|
|
|
|
# Then: Token should have required fields
|
|
self.assertIn("token", token_data)
|
|
self.assertIn("timestamp", token_data)
|
|
self.assertIn("signature", token_data)
|
|
|
|
# Then: Token should be 64 hex characters (256 bits)
|
|
self.assertEqual(len(token_data["token"]), 64)
|
|
|
|
# Then: Timestamp should be an integer
|
|
self.assertIsInstance(token_data["timestamp"], int)
|
|
|
|
# Then: Signature should be a hex string
|
|
self.assertTrue(all(c in "0123456789abcdef" for c in token_data["signature"]))
|
|
|
|
def test_validate_nickname_valid(self):
|
|
"""Test validate_nickname accepts valid nicknames."""
|
|
# Given: Valid nicknames
|
|
valid_nicknames = [
|
|
"user123",
|
|
"test_user",
|
|
"user-name",
|
|
"ABC",
|
|
"a_b_c",
|
|
]
|
|
|
|
# When/Then: All should be valid
|
|
for nickname in valid_nicknames:
|
|
is_valid, error = validate_nickname(nickname)
|
|
self.assertTrue(is_valid, f"{nickname} should be valid: {error}")
|
|
self.assertEqual(error, "")
|
|
|
|
def test_extract_nick_from_content(self):
|
|
"""Test extract_nick_from_content parses the #+NICK directive."""
|
|
# Given: Various social.org contents
|
|
cases = [
|
|
("#+TITLE: x\n#+NICK: alice\n", "alice"),
|
|
("#+nick: Bob\n", "Bob"),
|
|
(" #+NICK: carol \n", "carol"),
|
|
("#+TITLE: nope\n", None),
|
|
("", None),
|
|
(None, None),
|
|
]
|
|
|
|
# When/Then: Each content extracts the expected nick
|
|
for content, expected in cases:
|
|
self.assertEqual(extract_nick_from_content(content), expected)
|
|
|
|
def test_validate_nickname_invalid(self):
|
|
"""Test validate_nickname rejects invalid nicknames."""
|
|
# Given: Invalid nicknames
|
|
invalid_cases = [
|
|
("", "Nickname is required"),
|
|
("ab", "at least 3 characters"),
|
|
("a" * 51, "at most 50 characters"),
|
|
("user@name", "letters, numbers"),
|
|
("user name", "letters, numbers"),
|
|
]
|
|
|
|
# When/Then: All should be invalid
|
|
for nickname, expected_error in invalid_cases:
|
|
is_valid, error = validate_nickname(nickname)
|
|
self.assertFalse(is_valid, f"{nickname} should be invalid")
|
|
self.assertIn(expected_error.lower(), error.lower())
|
|
|
|
|
|
class SyncFeedsToRelayTaskTest(TestCase):
|
|
"""Test cases for the sync_feeds_to_relay periodic task."""
|
|
|
|
def setUp(self):
|
|
from app.hosting import tasks
|
|
|
|
self.tasks = tasks
|
|
|
|
# Three accounts: one with content, one redirected, one empty.
|
|
self.f_with_content = HostedFile.objects.create(
|
|
nickname="alice",
|
|
vfile_token="t-alice",
|
|
vfile_timestamp=1,
|
|
vfile_signature="s",
|
|
file_content="#+TITLE: Alice\n#+NICK: alice\n",
|
|
)
|
|
self.f_redirected = HostedFile.objects.create(
|
|
nickname="bob",
|
|
vfile_token="t-bob",
|
|
vfile_timestamp=1,
|
|
vfile_signature="s",
|
|
file_content="#+TITLE: Bob\n#+NICK: bob\n",
|
|
redirect_url="https://example.org/social.org",
|
|
)
|
|
self.f_empty = HostedFile.objects.create(
|
|
nickname="carol",
|
|
vfile_token="t-carol",
|
|
vfile_timestamp=1,
|
|
vfile_signature="s",
|
|
file_content="",
|
|
)
|
|
|
|
def test_sync_skipped_when_relay_url_empty(self):
|
|
"""Test the task is a no-op if RELAY_URL is empty."""
|
|
# Given: RELAY_URL is empty
|
|
from unittest.mock import patch
|
|
|
|
# When: We invoke the task
|
|
with patch.object(self.tasks.settings, "RELAY_URL", ""), \
|
|
patch.object(self.tasks.requests, "post") as post_mock, \
|
|
patch.object(self.tasks.time, "sleep") as sleep_mock:
|
|
self.tasks.sync_feeds_to_relay.call_local()
|
|
|
|
# Then: No HTTP call is performed
|
|
post_mock.assert_not_called()
|
|
sleep_mock.assert_not_called()
|
|
|
|
def test_sync_posts_only_public_feeds(self):
|
|
"""Test the task posts only feeds with content and no redirect."""
|
|
# Given: A configured relay
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
response = MagicMock()
|
|
response.status_code = 201
|
|
|
|
with patch.object(self.tasks.settings, "RELAY_URL", "https://relay.test"), \
|
|
patch.object(self.tasks.settings, "SITE_DOMAIN", "host.example.org"), \
|
|
patch.object(self.tasks.requests, "post", return_value=response) as post_mock, \
|
|
patch.object(self.tasks.time, "sleep") as sleep_mock:
|
|
self.tasks.sync_feeds_to_relay.call_local()
|
|
|
|
# Then: Only the alice feed is posted (bob redirected, carol empty)
|
|
self.assertEqual(post_mock.call_count, 1)
|
|
args, kwargs = post_mock.call_args
|
|
self.assertEqual(args[0], "https://relay.test/feeds/")
|
|
self.assertEqual(
|
|
kwargs["json"],
|
|
{"feed": "https://host.example.org/alice/social.org"},
|
|
)
|
|
# Then: No sleep needed for a single feed
|
|
sleep_mock.assert_not_called()
|
|
|
|
def test_sync_sleeps_between_requests(self):
|
|
"""Test the task sleeps 1 second between requests, but not after the last one."""
|
|
# Given: Two more accounts with content so we get three total
|
|
HostedFile.objects.create(
|
|
nickname="dave",
|
|
vfile_token="t-dave",
|
|
vfile_timestamp=1,
|
|
vfile_signature="s",
|
|
file_content="content",
|
|
)
|
|
HostedFile.objects.create(
|
|
nickname="eve",
|
|
vfile_token="t-eve",
|
|
vfile_timestamp=1,
|
|
vfile_signature="s",
|
|
file_content="content",
|
|
)
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
response = MagicMock()
|
|
response.status_code = 200
|
|
|
|
# When: We run the task with the relay configured
|
|
with patch.object(self.tasks.settings, "RELAY_URL", "https://relay.test"), \
|
|
patch.object(self.tasks.settings, "SITE_DOMAIN", "host.example.org"), \
|
|
patch.object(self.tasks.requests, "post", return_value=response) as post_mock, \
|
|
patch.object(self.tasks.time, "sleep") as sleep_mock:
|
|
self.tasks.sync_feeds_to_relay.call_local()
|
|
|
|
# Then: Three POSTs are made, two sleeps (between, not after the last)
|
|
self.assertEqual(post_mock.call_count, 3)
|
|
self.assertEqual(sleep_mock.call_count, 2)
|
|
for call in sleep_mock.call_args_list:
|
|
self.assertEqual(call.args[0], self.tasks.RELAY_SYNC_REQUEST_DELAY)
|
|
|
|
def test_sync_continues_on_request_failure(self):
|
|
"""Test that a failed request does not stop the task."""
|
|
from unittest.mock import patch
|
|
|
|
# Given: A second account with content so we have two feeds
|
|
HostedFile.objects.create(
|
|
nickname="dave",
|
|
vfile_token="t-dave",
|
|
vfile_timestamp=1,
|
|
vfile_signature="s",
|
|
file_content="content",
|
|
)
|
|
|
|
# When: requests.post raises for the first call and succeeds for the second
|
|
from unittest.mock import MagicMock
|
|
|
|
ok_response = MagicMock()
|
|
ok_response.status_code = 201
|
|
|
|
side_effects = [requests.ConnectionError("boom"), ok_response]
|
|
|
|
with patch.object(self.tasks.settings, "RELAY_URL", "https://relay.test"), \
|
|
patch.object(self.tasks.settings, "SITE_DOMAIN", "host.example.org"), \
|
|
patch.object(self.tasks.requests, "post", side_effect=side_effects) as post_mock, \
|
|
patch.object(self.tasks.time, "sleep"):
|
|
self.tasks.sync_feeds_to_relay.call_local()
|
|
|
|
# Then: Both feeds were attempted
|
|
self.assertEqual(post_mock.call_count, 2)
|