File size: 14,151 Bytes
942b115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
"""Integration tests: FastAPI TestClient against every endpoint (TEST-02),
covering auth, role enforcement, and client data isolation.

Uses the transactional-rollback fixtures in conftest.py so nothing written
here is ever actually persisted to the database (except the seed officer
row, which is idempotent and expected to persist -- see conftest.py).
"""

from __future__ import annotations

from app.config import settings
from tests.integration.conftest import (
    PAYMENT_LEGIT_PATTERN,
    TRANSFER_FRAUD_PATTERN,
    create_client,
    login,
)


def test_health(api_client):
    res = api_client.get("/health")
    assert res.status_code == 200
    assert res.json()["status"] == "ok"


def test_root_redirects_to_login(api_client):
    res = api_client.get("/", follow_redirects=False)
    assert res.status_code in (302, 307)
    assert res.headers["location"] == "/login"


# ------------------------------------------------------------------ auth --


def test_login_with_correct_credentials_succeeds(api_client):
    res = api_client.post(
        "/api/auth/login",
        json={"email": settings.SEED_OFFICER_EMAIL, "password": settings.SEED_OFFICER_PASSWORD},
    )
    assert res.status_code == 200
    body = res.json()
    assert body["role"] == "officer"
    assert "session_token" in res.cookies


def test_login_with_wrong_password_fails_generically(api_client):
    res = api_client.post(
        "/api/auth/login",
        json={"email": settings.SEED_OFFICER_EMAIL, "password": "definitely-wrong"},
    )
    assert res.status_code == 401
    assert res.json()["detail"] == "Incorrect email or password."


def test_login_with_unknown_email_fails_with_same_generic_error(api_client):
    res = api_client.post(
        "/api/auth/login", json={"email": "nobody@fakebankmail.com", "password": "whatever123"}
    )
    assert res.status_code == 401
    assert res.json()["detail"] == "Incorrect email or password."


def test_me_without_session_is_401(api_client):
    res = api_client.get("/api/auth/me")
    assert res.status_code == 401


def test_me_with_valid_session_returns_user(api_client, officer_identity):
    res = api_client.get("/api/auth/me", cookies=officer_identity["cookies"])
    assert res.status_code == 200
    assert res.json()["email"] == settings.SEED_OFFICER_EMAIL


def test_logout_invalidates_the_session(api_client, officer_identity):
    res = api_client.post("/api/auth/logout", cookies=officer_identity["cookies"])
    assert res.status_code == 200

    res = api_client.get("/api/auth/me", cookies=officer_identity["cookies"])
    assert res.status_code == 401


# --------------------------------------------------------- role boundaries --


def test_officer_route_rejects_anonymous(api_client):
    res = api_client.get("/api/officer/clients")
    assert res.status_code == 401


def test_officer_route_rejects_client_role(api_client, officer_identity):
    client_identity = create_client(
        api_client, officer_identity, email="roletest.client@fakebankmail.com", name="Role Test"
    )
    res = api_client.get("/api/officer/clients", cookies=client_identity["cookies"])
    assert res.status_code == 403


def test_client_route_rejects_officer_role(api_client, officer_identity):
    res = api_client.get("/api/client/profile", cookies=officer_identity["cookies"])
    assert res.status_code == 403


def test_client_route_rejects_anonymous(api_client):
    res = api_client.get("/api/client/profile")
    assert res.status_code == 401


# --------------------------------------------------------------- officer --


def test_officer_creates_client_and_lists_it(api_client, officer_identity):
    identity = create_client(
        api_client, officer_identity, email="create.test@fakebankmail.com", name="Create Test"
    )
    assert identity["user"]["role"] == "client"

    res = api_client.get("/api/officer/clients", cookies=officer_identity["cookies"])
    assert res.status_code == 200
    assert any(c["email"] == "create.test@fakebankmail.com" for c in res.json())


def test_officer_create_client_duplicate_email_conflicts(api_client, officer_identity):
    create_client(api_client, officer_identity, email="dupe@fakebankmail.com", name="First")
    res = api_client.post(
        "/api/officer/clients",
        json={
            "name": "Second",
            "email": "dupe@fakebankmail.com",
            "temp_password": "TempPass123!",
            "starting_balance": 100,
            "account_type": "checking",
        },
        cookies=officer_identity["cookies"],
    )
    assert res.status_code == 409


def test_officer_client_detail_shows_transactions_and_alerts(api_client, officer_identity, two_clients):
    client_a, _ = two_clients
    res = api_client.post(
        "/api/client/transactions",
        json={
            "type": "TRANSFER",
            "amount": 5000,
            "name_dest": "C_MULE",
            "simulated_at": "2026-03-01T10:00:00Z",
        },
        cookies=client_a["cookies"],
    )
    assert res.status_code == 200
    assert res.json()["risk_tier"] == "high"

    res = api_client.get(f"/api/officer/clients/{client_a['client_id']}", cookies=officer_identity["cookies"])
    assert res.status_code == 200
    detail = res.json()
    assert len(detail["transactions"]) == 1
    assert len(detail["alerts"]) == 1
    assert detail["current_balance"] == 0.0


def test_officer_sees_alerts_across_all_clients(api_client, officer_identity, two_clients):
    client_a, client_b = two_clients
    api_client.post(
        "/api/client/transactions",
        json={"type": "TRANSFER", "amount": 5000, "name_dest": "C_MULE_A", "simulated_at": "2026-03-01T10:00:00Z"},
        cookies=client_a["cookies"],
    )
    api_client.post(
        "/api/client/transactions",
        json={"type": "TRANSFER", "amount": 3000, "name_dest": "C_MULE_B", "simulated_at": "2026-03-01T11:00:00Z"},
        cookies=client_b["cookies"],
    )

    res = api_client.get("/api/officer/alerts", params={"page_size": 200}, cookies=officer_identity["cookies"])
    assert res.status_code == 200
    body = res.json()
    # Don't assert an exact global total -- the queue is cross-client by
    # design and may include real, already-committed alerts outside this
    # test's rolled-back transaction. Just confirm both fixture clients'
    # alerts are present in it.
    client_names = {item["client_name"] for item in body["items"]}
    assert {"Fixture Alpha", "Fixture Beta"}.issubset(client_names)


def test_officer_can_review_and_dismiss_alert(api_client, officer_identity, two_clients):
    client_a, _ = two_clients
    api_client.post(
        "/api/client/transactions",
        json={"type": "TRANSFER", "amount": 5000, "name_dest": "C_MULE", "simulated_at": "2026-03-01T10:00:00Z"},
        cookies=client_a["cookies"],
    )
    alerts = api_client.get(
        "/api/officer/alerts", params={"disposition": "open"}, cookies=officer_identity["cookies"]
    ).json()["items"]
    alert_id = alerts[0]["id"]

    res = api_client.patch(
        f"/api/officer/alerts/{alert_id}", json={"disposition": "reviewed"}, cookies=officer_identity["cookies"]
    )
    assert res.status_code == 200
    assert res.json()["disposition"] == "reviewed"
    assert res.json()["reviewed_at"] is not None


def test_officer_can_reject_alert_and_reverse_transaction(api_client, officer_identity, two_clients):
    client_a, _ = two_clients
    res = api_client.post(
        "/api/client/transactions",
        json={"type": "TRANSFER", "amount": 5000, "name_dest": "C_MULE", "simulated_at": "2026-03-01T10:00:00Z"},
        cookies=client_a["cookies"],
    )
    assert res.status_code == 200
    assert api_client.get("/api/client/profile", cookies=client_a["cookies"]).json()["current_balance"] == 0.0

    alerts = api_client.get(
        "/api/officer/alerts", params={"disposition": "open"}, cookies=officer_identity["cookies"]
    ).json()["items"]
    alert_id = next(a["id"] for a in alerts if a["client_id"] == client_a["client_id"])

    res = api_client.patch(
        f"/api/officer/alerts/{alert_id}", json={"disposition": "rejected"}, cookies=officer_identity["cookies"]
    )
    assert res.status_code == 200
    body = res.json()
    assert body["disposition"] == "rejected"
    assert body["reversed"] is True

    # the amount is back on the client's balance
    profile = api_client.get("/api/client/profile", cookies=client_a["cookies"]).json()
    assert profile["current_balance"] == 5000.0

    # the client's own alert view reflects the reversal in plain language
    client_alerts = api_client.get("/api/client/alerts", cookies=client_a["cookies"]).json()
    assert client_alerts[0]["reversed"] is True
    assert "reversed" in client_alerts[0]["status_message"].lower()

    # officer's client detail view shows the transaction as reversed too
    detail = api_client.get(
        f"/api/officer/clients/{client_a['client_id']}", cookies=officer_identity["cookies"]
    ).json()
    assert detail["current_balance"] == 5000.0
    assert detail["transactions"][0]["reversed"] is True


def test_officer_patch_nonexistent_alert_404s(api_client, officer_identity):
    res = api_client.patch(
        "/api/officer/alerts/999999999", json={"disposition": "dismissed"}, cookies=officer_identity["cookies"]
    )
    assert res.status_code == 404


def test_officer_stats_endpoints(api_client, officer_identity):
    for path in ["/api/officer/stats/overview", "/api/officer/stats/eda", "/api/officer/stats/model-performance"]:
        res = api_client.get(path, cookies=officer_identity["cookies"])
        assert res.status_code == 200, path


def test_officer_predict_and_batch(api_client, officer_identity):
    res = api_client.post("/api/predict", json=TRANSFER_FRAUD_PATTERN, cookies=officer_identity["cookies"])
    assert res.status_code == 200
    assert res.json()["risk_tier"] == "high"

    res = api_client.post(
        "/api/transactions/batch",
        json={"transactions": [PAYMENT_LEGIT_PATTERN]},
        cookies=officer_identity["cookies"],
    )
    assert res.status_code == 200
    assert res.json()["results"][0]["risk_tier"] == "low"


def test_predict_rejects_client_role(api_client, officer_identity):
    client_identity = create_client(
        api_client, officer_identity, email="predicttest@fakebankmail.com", name="Predict Test"
    )
    res = api_client.post("/api/predict", json=TRANSFER_FRAUD_PATTERN, cookies=client_identity["cookies"])
    assert res.status_code == 403


# ---------------------------------------------------------------- client --


def test_client_profile_reflects_balance_and_alerts(api_client, two_clients):
    client_a, _ = two_clients
    res = api_client.get("/api/client/profile", cookies=client_a["cookies"])
    assert res.status_code == 200
    body = res.json()
    assert body["current_balance"] == 5000.0
    assert body["open_alerts"] == []


def test_client_submits_legit_transaction(api_client, two_clients):
    client_a, _ = two_clients
    res = api_client.post(
        "/api/client/transactions",
        json={"type": "PAYMENT", "amount": 50, "name_dest": "MSHOP", "simulated_at": "2026-03-01T09:00:00Z"},
        cookies=client_a["cookies"],
    )
    assert res.status_code == 200
    body = res.json()
    assert body["risk_tier"] == "low"
    assert body["top_features"] is None  # SHAP internals are officer-only


def test_client_cannot_overdraw(api_client, two_clients):
    client_a, _ = two_clients
    res = api_client.post(
        "/api/client/transactions",
        json={"type": "TRANSFER", "amount": 999999, "name_dest": "C_X", "simulated_at": "2026-03-01T09:00:00Z"},
        cookies=client_a["cookies"],
    )
    assert res.status_code == 422


def test_client_sees_only_own_transactions_and_alerts(api_client, two_clients):
    client_a, client_b = two_clients

    api_client.post(
        "/api/client/transactions",
        json={"type": "TRANSFER", "amount": 5000, "name_dest": "C_MULE_A", "simulated_at": "2026-03-01T10:00:00Z"},
        cookies=client_a["cookies"],
    )
    api_client.post(
        "/api/client/transactions",
        json={"type": "PAYMENT", "amount": 20, "name_dest": "MSHOP", "simulated_at": "2026-03-01T09:00:00Z"},
        cookies=client_b["cookies"],
    )

    res_a = api_client.get("/api/client/transactions", cookies=client_a["cookies"])
    res_b = api_client.get("/api/client/transactions", cookies=client_b["cookies"])
    assert len(res_a.json()) == 1
    assert len(res_b.json()) == 1
    assert res_a.json()[0]["name_dest"] == "C_MULE_A"
    assert res_b.json()[0]["name_dest"] == "MSHOP"

    alerts_a = api_client.get("/api/client/alerts", cookies=client_a["cookies"]).json()
    alerts_b = api_client.get("/api/client/alerts", cookies=client_b["cookies"]).json()
    assert len(alerts_a) == 1  # the fraud-pattern transfer
    assert len(alerts_b) == 0  # the legit payment


def test_client_cannot_access_another_clients_data_via_officer_routes(api_client, officer_identity, two_clients):
    """A client has no client_id parameter anywhere in their own routes, so
    the only way to attempt cross-client access is hitting an officer
    route directly -- confirm that's blocked by role, not by omission."""
    client_a, _ = two_clients
    res = api_client.get(f"/api/officer/clients/{client_a['client_id']}", cookies=client_a["cookies"])
    assert res.status_code == 403


# ---------------------------------------------------------------- pages --


def test_html_pages_render(api_client):
    for path in [
        "/login",
        "/officer",
        "/officer/clients",
        "/officer/alerts",
        "/officer/insights",
        "/officer/model",
        "/client",
        "/client/transactions/new",
        "/client/transactions",
        "/client/alerts",
    ]:
        res = api_client.get(path)
        assert res.status_code == 200, path
        assert "text/html" in res.headers["content-type"]


def test_openapi_docs_available(api_client):
    assert api_client.get("/docs").status_code == 200
    assert api_client.get("/openapi.json").status_code == 200