@malamute/ai-rules 1.0.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +272 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/_shared/rules/conventions/documentation.md +324 -0
- package/configs/_shared/rules/conventions/git.md +265 -0
- package/configs/_shared/rules/conventions/npm.md +80 -0
- package/configs/_shared/{.claude/rules → rules/conventions}/performance.md +1 -1
- package/configs/_shared/rules/conventions/principles.md +334 -0
- package/configs/_shared/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/rules/devops/docker.md +275 -0
- package/configs/_shared/rules/devops/nx.md +194 -0
- package/configs/_shared/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/rules/lang/python/async.md +337 -0
- package/configs/_shared/rules/lang/python/celery.md +476 -0
- package/configs/_shared/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/rules/lang/python/python.md +172 -0
- package/configs/_shared/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/rules/quality/error-handling.md +48 -0
- package/configs/_shared/rules/quality/logging.md +45 -0
- package/configs/_shared/rules/quality/observability.md +240 -0
- package/configs/_shared/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/rules/security/secrets-management.md +222 -0
- package/configs/_shared/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/{.claude/commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/{.claude/commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/{.claude/commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/angular/{.claude/rules → rules/core}/components.md +69 -15
- package/configs/angular/rules/core/resource.md +285 -0
- package/configs/angular/rules/core/signals.md +323 -0
- package/configs/angular/rules/http.md +338 -0
- package/configs/angular/rules/routing.md +291 -0
- package/configs/angular/rules/ssr.md +312 -0
- package/configs/angular/rules/state/signal-store.md +408 -0
- package/configs/angular/{.claude/rules → rules/state}/state.md +2 -2
- package/configs/angular/{.claude/rules → rules}/testing.md +7 -7
- package/configs/angular/rules/ui/aria.md +422 -0
- package/configs/angular/rules/ui/forms.md +424 -0
- package/configs/angular/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/{.claude/settings.json → settings.json} +3 -0
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/dotnet/rules/background-services.md +552 -0
- package/configs/dotnet/rules/configuration.md +426 -0
- package/configs/dotnet/rules/ddd.md +447 -0
- package/configs/dotnet/rules/dependency-injection.md +343 -0
- package/configs/dotnet/rules/mediatr.md +320 -0
- package/configs/dotnet/rules/middleware.md +489 -0
- package/configs/dotnet/rules/result-pattern.md +363 -0
- package/configs/dotnet/rules/validation.md +388 -0
- package/configs/dotnet/settings.json +29 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/fastapi/rules/background-tasks.md +254 -0
- package/configs/fastapi/rules/dependencies.md +170 -0
- package/configs/{python/.claude → fastapi}/rules/fastapi.md +61 -1
- package/configs/fastapi/rules/lifespan.md +274 -0
- package/configs/fastapi/rules/middleware.md +229 -0
- package/configs/fastapi/rules/pydantic.md +433 -0
- package/configs/fastapi/rules/responses.md +251 -0
- package/configs/fastapi/rules/routers.md +202 -0
- package/configs/fastapi/rules/security.md +222 -0
- package/configs/fastapi/rules/testing.md +251 -0
- package/configs/fastapi/rules/websockets.md +298 -0
- package/configs/fastapi/settings.json +35 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/flask/rules/blueprints.md +208 -0
- package/configs/flask/rules/cli.md +285 -0
- package/configs/flask/rules/configuration.md +281 -0
- package/configs/flask/rules/context.md +238 -0
- package/configs/flask/rules/error-handlers.md +278 -0
- package/configs/flask/rules/extensions.md +278 -0
- package/configs/flask/rules/flask.md +171 -0
- package/configs/flask/rules/marshmallow.md +206 -0
- package/configs/flask/rules/security.md +267 -0
- package/configs/flask/rules/testing.md +284 -0
- package/configs/flask/settings.json +35 -0
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nestjs/rules/common-patterns.md +300 -0
- package/configs/nestjs/rules/filters.md +376 -0
- package/configs/nestjs/rules/interceptors.md +317 -0
- package/configs/nestjs/rules/middleware.md +321 -0
- package/configs/nestjs/{.claude/rules → rules}/modules.md +26 -0
- package/configs/nestjs/rules/pipes.md +351 -0
- package/configs/nestjs/rules/websockets.md +451 -0
- package/configs/nestjs/settings.json +31 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/configs/nextjs/rules/api-routes.md +358 -0
- package/configs/nextjs/rules/authentication.md +355 -0
- package/configs/nextjs/{.claude/rules → rules}/components.md +52 -0
- package/configs/nextjs/rules/data-fetching.md +249 -0
- package/configs/nextjs/rules/database.md +400 -0
- package/configs/nextjs/rules/middleware.md +303 -0
- package/configs/nextjs/rules/routing.md +324 -0
- package/configs/nextjs/rules/seo.md +350 -0
- package/configs/nextjs/rules/server-actions.md +353 -0
- package/configs/nextjs/{.claude/rules → rules}/state/zustand.md +6 -6
- package/configs/nextjs/{.claude/settings.json → settings.json} +7 -0
- package/package.json +24 -9
- package/src/cli.js +218 -0
- package/src/config.js +63 -0
- package/src/index.js +4 -0
- package/src/installer.js +414 -0
- package/src/merge.js +109 -0
- package/src/tech-config.json +45 -0
- package/src/utils.js +88 -0
- package/configs/dotnet/.claude/settings.json +0 -9
- package/configs/nestjs/.claude/settings.json +0 -15
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/{.claude/rules → rules/domain/frontend}/accessibility.md +0 -0
- /package/configs/_shared/{.claude/rules → rules/security}/security.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/debug/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/learning/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/spec/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/git}/review/SKILL.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/api.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/architecture.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/database/efcore.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/testing.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/auth.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/database/prisma.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/database/typeorm.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/testing.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/validation.md +0 -0
- /package/configs/nextjs/{.claude/rules → rules}/state/redux-toolkit.md +0 -0
- /package/configs/nextjs/{.claude/rules → rules}/testing.md +0 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Flask Security Patterns
|
|
7
|
+
|
|
8
|
+
## Password Hashing
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from werkzeug.security import generate_password_hash, check_password_hash
|
|
12
|
+
|
|
13
|
+
class User(db.Model):
|
|
14
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
15
|
+
email: Mapped[str] = mapped_column(unique=True)
|
|
16
|
+
password_hash: Mapped[str]
|
|
17
|
+
|
|
18
|
+
def set_password(self, password: str):
|
|
19
|
+
self.password_hash = generate_password_hash(password)
|
|
20
|
+
|
|
21
|
+
def check_password(self, password: str) -> bool:
|
|
22
|
+
return check_password_hash(self.password_hash, password)
|
|
23
|
+
|
|
24
|
+
# Usage
|
|
25
|
+
user = User(email="test@example.com")
|
|
26
|
+
user.set_password("secure_password")
|
|
27
|
+
|
|
28
|
+
if user.check_password("secure_password"):
|
|
29
|
+
print("Password correct")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## CSRF Protection
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from flask_wtf.csrf import CSRFProtect
|
|
36
|
+
|
|
37
|
+
csrf = CSRFProtect()
|
|
38
|
+
csrf.init_app(app)
|
|
39
|
+
|
|
40
|
+
# In templates
|
|
41
|
+
<form method="post">
|
|
42
|
+
{{ csrf_token() }}
|
|
43
|
+
<!-- or -->
|
|
44
|
+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
45
|
+
</form>
|
|
46
|
+
|
|
47
|
+
# For AJAX requests
|
|
48
|
+
<script>
|
|
49
|
+
fetch('/api/endpoint', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
'X-CSRFToken': '{{ csrf_token() }}'
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify(data)
|
|
55
|
+
});
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
# Exempt API routes from CSRF
|
|
59
|
+
@csrf.exempt
|
|
60
|
+
@app.route("/api/webhook", methods=["POST"])
|
|
61
|
+
def webhook():
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
# Or exempt entire blueprint
|
|
65
|
+
csrf.exempt(api_bp)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Security Headers
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from flask_talisman import Talisman
|
|
72
|
+
|
|
73
|
+
# Basic security headers
|
|
74
|
+
Talisman(app, force_https=True)
|
|
75
|
+
|
|
76
|
+
# Custom configuration
|
|
77
|
+
Talisman(
|
|
78
|
+
app,
|
|
79
|
+
force_https=True,
|
|
80
|
+
strict_transport_security=True,
|
|
81
|
+
strict_transport_security_max_age=31536000,
|
|
82
|
+
content_security_policy={
|
|
83
|
+
"default-src": "'self'",
|
|
84
|
+
"script-src": ["'self'", "cdn.example.com"],
|
|
85
|
+
"style-src": ["'self'", "'unsafe-inline'"],
|
|
86
|
+
"img-src": ["'self'", "data:", "*.example.com"],
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Manual headers
|
|
91
|
+
@app.after_request
|
|
92
|
+
def add_security_headers(response):
|
|
93
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
94
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
95
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
96
|
+
return response
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Session Security
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Configuration
|
|
103
|
+
app.config.update(
|
|
104
|
+
SECRET_KEY=os.environ["SECRET_KEY"],
|
|
105
|
+
SESSION_COOKIE_SECURE=True, # HTTPS only
|
|
106
|
+
SESSION_COOKIE_HTTPONLY=True, # No JavaScript access
|
|
107
|
+
SESSION_COOKIE_SAMESITE="Lax", # CSRF protection
|
|
108
|
+
PERMANENT_SESSION_LIFETIME=timedelta(hours=24),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Server-side sessions with Redis
|
|
112
|
+
from flask_session import Session
|
|
113
|
+
|
|
114
|
+
app.config["SESSION_TYPE"] = "redis"
|
|
115
|
+
app.config["SESSION_REDIS"] = redis.from_url(os.environ["REDIS_URL"])
|
|
116
|
+
Session(app)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Input Validation
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from markupsafe import escape
|
|
123
|
+
from marshmallow import Schema, fields, validate
|
|
124
|
+
|
|
125
|
+
# Always validate input
|
|
126
|
+
class UserInputSchema(Schema):
|
|
127
|
+
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
|
|
128
|
+
email = fields.Email(required=True)
|
|
129
|
+
bio = fields.Str(validate=validate.Length(max=500))
|
|
130
|
+
|
|
131
|
+
@users_bp.route("/", methods=["POST"])
|
|
132
|
+
def create_user():
|
|
133
|
+
schema = UserInputSchema()
|
|
134
|
+
data = schema.load(request.get_json()) # Validates and sanitizes
|
|
135
|
+
user = UserService.create(data)
|
|
136
|
+
return jsonify(UserSchema().dump(user)), 201
|
|
137
|
+
|
|
138
|
+
# Escape output for HTML
|
|
139
|
+
@app.route("/search")
|
|
140
|
+
def search():
|
|
141
|
+
query = request.args.get("q", "")
|
|
142
|
+
safe_query = escape(query) # Prevents XSS
|
|
143
|
+
return render_template("search.html", query=safe_query)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## SQL Injection Prevention
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# GOOD - Use ORM or parameterized queries
|
|
150
|
+
user = User.query.filter_by(email=email).first()
|
|
151
|
+
|
|
152
|
+
# GOOD - Raw SQL with parameters
|
|
153
|
+
result = db.session.execute(
|
|
154
|
+
text("SELECT * FROM users WHERE email = :email"),
|
|
155
|
+
{"email": email}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# BAD - String interpolation (SQL injection vulnerable!)
|
|
159
|
+
result = db.session.execute(f"SELECT * FROM users WHERE email = '{email}'")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## API Key Authentication
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from functools import wraps
|
|
166
|
+
|
|
167
|
+
def require_api_key(f):
|
|
168
|
+
@wraps(f)
|
|
169
|
+
def decorated(*args, **kwargs):
|
|
170
|
+
api_key = request.headers.get("X-API-Key")
|
|
171
|
+
|
|
172
|
+
if not api_key:
|
|
173
|
+
return jsonify({"error": "API key required"}), 401
|
|
174
|
+
|
|
175
|
+
# Constant-time comparison to prevent timing attacks
|
|
176
|
+
import hmac
|
|
177
|
+
valid_key = current_app.config["API_KEY"]
|
|
178
|
+
if not hmac.compare_digest(api_key, valid_key):
|
|
179
|
+
return jsonify({"error": "Invalid API key"}), 401
|
|
180
|
+
|
|
181
|
+
return f(*args, **kwargs)
|
|
182
|
+
return decorated
|
|
183
|
+
|
|
184
|
+
@api_bp.route("/data")
|
|
185
|
+
@require_api_key
|
|
186
|
+
def get_data():
|
|
187
|
+
return jsonify({"data": "sensitive"})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Rate Limiting
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from flask_limiter import Limiter
|
|
194
|
+
from flask_limiter.util import get_remote_address
|
|
195
|
+
|
|
196
|
+
limiter = Limiter(
|
|
197
|
+
key_func=get_remote_address,
|
|
198
|
+
default_limits=["200 per day", "50 per hour"],
|
|
199
|
+
storage_uri="redis://localhost:6379",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@auth_bp.route("/login", methods=["POST"])
|
|
203
|
+
@limiter.limit("5 per minute")
|
|
204
|
+
def login():
|
|
205
|
+
...
|
|
206
|
+
|
|
207
|
+
# Per-user rate limiting
|
|
208
|
+
@limiter.limit("100 per hour", key_func=lambda: current_user.id)
|
|
209
|
+
def user_endpoint():
|
|
210
|
+
...
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Secure File Uploads
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from werkzeug.utils import secure_filename
|
|
217
|
+
import os
|
|
218
|
+
|
|
219
|
+
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"}
|
|
220
|
+
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
|
|
221
|
+
|
|
222
|
+
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
|
223
|
+
|
|
224
|
+
def allowed_file(filename: str) -> bool:
|
|
225
|
+
return "." in filename and \
|
|
226
|
+
filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
227
|
+
|
|
228
|
+
@files_bp.route("/upload", methods=["POST"])
|
|
229
|
+
def upload_file():
|
|
230
|
+
if "file" not in request.files:
|
|
231
|
+
return jsonify({"error": "No file provided"}), 400
|
|
232
|
+
|
|
233
|
+
file = request.files["file"]
|
|
234
|
+
|
|
235
|
+
if file.filename == "":
|
|
236
|
+
return jsonify({"error": "No file selected"}), 400
|
|
237
|
+
|
|
238
|
+
if not allowed_file(file.filename):
|
|
239
|
+
return jsonify({"error": "File type not allowed"}), 400
|
|
240
|
+
|
|
241
|
+
# Secure the filename
|
|
242
|
+
filename = secure_filename(file.filename)
|
|
243
|
+
|
|
244
|
+
# Generate unique filename
|
|
245
|
+
unique_filename = f"{uuid.uuid4()}_{filename}"
|
|
246
|
+
|
|
247
|
+
# Save to secure location
|
|
248
|
+
file.save(os.path.join(app.config["UPLOAD_FOLDER"], unique_filename))
|
|
249
|
+
|
|
250
|
+
return jsonify({"filename": unique_filename}), 201
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Secrets Management
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
import os
|
|
257
|
+
|
|
258
|
+
# GOOD - Environment variables
|
|
259
|
+
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
|
|
260
|
+
app.config["DATABASE_URL"] = os.environ["DATABASE_URL"]
|
|
261
|
+
|
|
262
|
+
# GOOD - Secrets file (not in repo)
|
|
263
|
+
# config/secrets.py (in .gitignore)
|
|
264
|
+
|
|
265
|
+
# BAD - Hardcoded secrets
|
|
266
|
+
app.config["SECRET_KEY"] = "hardcoded-secret" # NEVER do this
|
|
267
|
+
```
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "tests/**/*.py"
|
|
4
|
+
- "**/test_*.py"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Flask Testing Patterns
|
|
8
|
+
|
|
9
|
+
## Test Configuration
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
# conftest.py
|
|
13
|
+
import pytest
|
|
14
|
+
from app import create_app, db
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(scope="session")
|
|
17
|
+
def app():
|
|
18
|
+
"""Create application for testing."""
|
|
19
|
+
app = create_app("testing")
|
|
20
|
+
return app
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def client(app):
|
|
24
|
+
"""Create test client."""
|
|
25
|
+
return app.test_client()
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def runner(app):
|
|
29
|
+
"""Create CLI test runner."""
|
|
30
|
+
return app.test_cli_runner()
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def db_session(app):
|
|
34
|
+
"""Create database session for testing."""
|
|
35
|
+
with app.app_context():
|
|
36
|
+
db.create_all()
|
|
37
|
+
yield db.session
|
|
38
|
+
db.session.rollback()
|
|
39
|
+
db.drop_all()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Testing Config
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# config.py
|
|
46
|
+
class TestingConfig:
|
|
47
|
+
TESTING = True
|
|
48
|
+
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
|
49
|
+
WTF_CSRF_ENABLED = False
|
|
50
|
+
SECRET_KEY = "test-secret-key"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API Endpoint Tests
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
class TestUsersAPI:
|
|
57
|
+
def test_create_user(self, client, db_session):
|
|
58
|
+
response = client.post("/api/v1/users", json={
|
|
59
|
+
"email": "test@example.com",
|
|
60
|
+
"password": "password123",
|
|
61
|
+
"name": "Test User",
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
assert response.status_code == 201
|
|
65
|
+
data = response.get_json()
|
|
66
|
+
assert data["email"] == "test@example.com"
|
|
67
|
+
assert "id" in data
|
|
68
|
+
assert "password" not in data
|
|
69
|
+
|
|
70
|
+
def test_create_user_duplicate_email(self, client, db_session):
|
|
71
|
+
# Create existing user
|
|
72
|
+
user = User(email="existing@example.com", name="Existing")
|
|
73
|
+
db_session.add(user)
|
|
74
|
+
db_session.commit()
|
|
75
|
+
|
|
76
|
+
response = client.post("/api/v1/users", json={
|
|
77
|
+
"email": "existing@example.com",
|
|
78
|
+
"password": "password123",
|
|
79
|
+
"name": "New User",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
assert response.status_code == 409
|
|
83
|
+
|
|
84
|
+
def test_get_user_not_found(self, client):
|
|
85
|
+
response = client.get("/api/v1/users/99999")
|
|
86
|
+
assert response.status_code == 404
|
|
87
|
+
|
|
88
|
+
def test_list_users_pagination(self, client, db_session):
|
|
89
|
+
# Create users
|
|
90
|
+
for i in range(25):
|
|
91
|
+
db_session.add(User(email=f"user{i}@example.com", name=f"User {i}"))
|
|
92
|
+
db_session.commit()
|
|
93
|
+
|
|
94
|
+
response = client.get("/api/v1/users?page=2&size=10")
|
|
95
|
+
|
|
96
|
+
assert response.status_code == 200
|
|
97
|
+
data = response.get_json()
|
|
98
|
+
assert len(data["items"]) == 10
|
|
99
|
+
assert data["total"] == 25
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Authentication Tests
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
@pytest.fixture
|
|
106
|
+
def auth_headers(client, db_session):
|
|
107
|
+
"""Create authenticated user and return headers."""
|
|
108
|
+
user = User(email="auth@example.com", name="Auth User")
|
|
109
|
+
user.set_password("password123")
|
|
110
|
+
db_session.add(user)
|
|
111
|
+
db_session.commit()
|
|
112
|
+
|
|
113
|
+
response = client.post("/api/v1/auth/login", json={
|
|
114
|
+
"email": "auth@example.com",
|
|
115
|
+
"password": "password123",
|
|
116
|
+
})
|
|
117
|
+
token = response.get_json()["access_token"]
|
|
118
|
+
|
|
119
|
+
return {"Authorization": f"Bearer {token}"}
|
|
120
|
+
|
|
121
|
+
class TestAuthenticatedEndpoints:
|
|
122
|
+
def test_get_me_unauthorized(self, client):
|
|
123
|
+
response = client.get("/api/v1/users/me")
|
|
124
|
+
assert response.status_code == 401
|
|
125
|
+
|
|
126
|
+
def test_get_me_authorized(self, client, auth_headers):
|
|
127
|
+
response = client.get("/api/v1/users/me", headers=auth_headers)
|
|
128
|
+
assert response.status_code == 200
|
|
129
|
+
assert response.get_json()["email"] == "auth@example.com"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Form Tests
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
def test_login_form(client, db_session):
|
|
136
|
+
# Create user
|
|
137
|
+
user = User(email="test@example.com", name="Test")
|
|
138
|
+
user.set_password("password")
|
|
139
|
+
db_session.add(user)
|
|
140
|
+
db_session.commit()
|
|
141
|
+
|
|
142
|
+
response = client.post("/login", data={
|
|
143
|
+
"email": "test@example.com",
|
|
144
|
+
"password": "password",
|
|
145
|
+
}, follow_redirects=True)
|
|
146
|
+
|
|
147
|
+
assert response.status_code == 200
|
|
148
|
+
assert b"Dashboard" in response.data
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## File Upload Tests
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from io import BytesIO
|
|
155
|
+
|
|
156
|
+
def test_file_upload(client, auth_headers):
|
|
157
|
+
data = {
|
|
158
|
+
"file": (BytesIO(b"file content"), "test.txt"),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
response = client.post(
|
|
162
|
+
"/api/v1/files/upload",
|
|
163
|
+
data=data,
|
|
164
|
+
content_type="multipart/form-data",
|
|
165
|
+
headers=auth_headers,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
assert response.status_code == 201
|
|
169
|
+
assert response.get_json()["filename"] == "test.txt"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## CLI Command Tests
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
def test_init_db_command(runner):
|
|
176
|
+
result = runner.invoke(args=["init-db"])
|
|
177
|
+
assert result.exit_code == 0
|
|
178
|
+
assert "Database initialized" in result.output
|
|
179
|
+
|
|
180
|
+
def test_create_user_command(runner, db_session):
|
|
181
|
+
result = runner.invoke(args=[
|
|
182
|
+
"create-user",
|
|
183
|
+
"test@example.com",
|
|
184
|
+
"Test User",
|
|
185
|
+
"--admin",
|
|
186
|
+
])
|
|
187
|
+
|
|
188
|
+
assert result.exit_code == 0
|
|
189
|
+
assert "Created user" in result.output
|
|
190
|
+
|
|
191
|
+
user = User.query.filter_by(email="test@example.com").first()
|
|
192
|
+
assert user is not None
|
|
193
|
+
assert user.is_admin is True
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Mocking External Services
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from unittest.mock import patch, MagicMock
|
|
200
|
+
|
|
201
|
+
def test_send_email(client, db_session):
|
|
202
|
+
with patch("app.services.email.mail") as mock_mail:
|
|
203
|
+
response = client.post("/api/v1/users", json={
|
|
204
|
+
"email": "test@example.com",
|
|
205
|
+
"password": "password123",
|
|
206
|
+
"name": "Test User",
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
assert response.status_code == 201
|
|
210
|
+
mock_mail.send.assert_called_once()
|
|
211
|
+
|
|
212
|
+
def test_external_api_call(client):
|
|
213
|
+
with patch("app.services.external.requests") as mock_requests:
|
|
214
|
+
mock_requests.get.return_value = MagicMock(
|
|
215
|
+
status_code=200,
|
|
216
|
+
json=lambda: {"data": "mocked"},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
response = client.get("/api/v1/external-data")
|
|
220
|
+
|
|
221
|
+
assert response.status_code == 200
|
|
222
|
+
assert response.get_json()["data"] == "mocked"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Parametrized Tests
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
@pytest.mark.parametrize("email,expected_status", [
|
|
229
|
+
("valid@example.com", 201),
|
|
230
|
+
("also.valid@test.co.uk", 201),
|
|
231
|
+
("invalid", 400),
|
|
232
|
+
("missing@", 400),
|
|
233
|
+
("@nodomain.com", 400),
|
|
234
|
+
])
|
|
235
|
+
def test_email_validation(client, email: str, expected_status: int):
|
|
236
|
+
response = client.post("/api/v1/users", json={
|
|
237
|
+
"email": email,
|
|
238
|
+
"password": "password123",
|
|
239
|
+
"name": "Test",
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
assert response.status_code == expected_status
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Test Markers
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
# pytest.ini or pyproject.toml
|
|
249
|
+
[tool.pytest.ini_options]
|
|
250
|
+
markers = [
|
|
251
|
+
"slow: marks tests as slow",
|
|
252
|
+
"integration: requires database",
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
# Usage
|
|
256
|
+
@pytest.mark.slow
|
|
257
|
+
def test_heavy_computation():
|
|
258
|
+
...
|
|
259
|
+
|
|
260
|
+
@pytest.mark.integration
|
|
261
|
+
def test_database_query(db_session):
|
|
262
|
+
...
|
|
263
|
+
|
|
264
|
+
# Run specific markers
|
|
265
|
+
# pytest -m "not slow"
|
|
266
|
+
# pytest -m integration
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Coverage Configuration
|
|
270
|
+
|
|
271
|
+
```toml
|
|
272
|
+
# pyproject.toml
|
|
273
|
+
[tool.coverage.run]
|
|
274
|
+
source = ["app"]
|
|
275
|
+
omit = ["*/tests/*", "*/__init__.py"]
|
|
276
|
+
|
|
277
|
+
[tool.coverage.report]
|
|
278
|
+
exclude_lines = [
|
|
279
|
+
"pragma: no cover",
|
|
280
|
+
"if TYPE_CHECKING:",
|
|
281
|
+
"raise NotImplementedError",
|
|
282
|
+
]
|
|
283
|
+
fail_under = 80
|
|
284
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(python *)",
|
|
5
|
+
"Bash(python3 *)",
|
|
6
|
+
"Bash(flask *)",
|
|
7
|
+
"Bash(gunicorn *)",
|
|
8
|
+
"Bash(pytest *)",
|
|
9
|
+
"Bash(ruff *)",
|
|
10
|
+
"Bash(mypy *)",
|
|
11
|
+
"Bash(alembic *)",
|
|
12
|
+
"Bash(uv *)",
|
|
13
|
+
"Bash(poetry *)",
|
|
14
|
+
"Bash(pip *)",
|
|
15
|
+
"Bash(pip3 *)",
|
|
16
|
+
"Read",
|
|
17
|
+
"Edit",
|
|
18
|
+
"Write"
|
|
19
|
+
],
|
|
20
|
+
"deny": [
|
|
21
|
+
"Bash(git push *)",
|
|
22
|
+
"Bash(git push)",
|
|
23
|
+
"Bash(rm -rf *)",
|
|
24
|
+
"Read(.env)",
|
|
25
|
+
"Read(.env.*)",
|
|
26
|
+
"Read(**/secrets/**)",
|
|
27
|
+
"Read(**/*.pem)"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"env": {
|
|
31
|
+
"FLASK_DEBUG": "1",
|
|
32
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
33
|
+
"PYTHONUNBUFFERED": "1"
|
|
34
|
+
}
|
|
35
|
+
}
|