File size: 10,882 Bytes
6ff09d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Integration tests for the mock protocol server.

Purpose: pin the contract between spec.py and the FastAPI routes. If these
pass, every response code the matcher cares about is actually producible
from the real server.

Organization follows the spec's endpoint order.
"""

from fastapi.testclient import TestClient
import pytest

from server.protocol_server import create_server


AUTH_FULL = {"Authorization": "Bearer token_full"}
AUTH_READ = {"Authorization": "Bearer token_read"}
AUTH_WRITE = {"Authorization": "Bearer token_write"}
AUTH_ADMIN = {"Authorization": "Bearer token_admin"}
AUTH_JUNK = {"Authorization": "Bearer nonexistent"}
AUTH_MALFORMED = {"Authorization": "Basic abc"}


@pytest.fixture
def client() -> TestClient:
    return TestClient(create_server().app)


# ---------------------------------------------------------------------------
# Health (no auth)
# ---------------------------------------------------------------------------

def test_health_no_auth(client):
    r = client.get("/_/health")
    assert r.status_code == 200
    assert r.json()["ok"] is True


# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------

def test_missing_auth_returns_401(client):
    r = client.get("/users")
    assert r.status_code == 401


def test_malformed_auth_returns_401(client):
    r = client.get("/users", headers=AUTH_MALFORMED)
    assert r.status_code == 401


def test_invalid_token_returns_401(client):
    r = client.get("/users", headers=AUTH_JUNK)
    assert r.status_code == 401


def test_insufficient_scope_returns_403(client):
    # token_write does NOT have users:read
    r = client.get("/users", headers=AUTH_WRITE)
    assert r.status_code == 403
    assert r.json()["detail"]["error"] == "insufficient_scope"


def test_admin_only_endpoint_rejects_non_admin(client):
    r = client.get("/auth/scopes", headers=AUTH_READ)
    assert r.status_code == 403


def test_admin_endpoint_succeeds_with_admin_scope(client):
    r = client.get("/auth/scopes", headers=AUTH_ADMIN)
    assert r.status_code == 200
    assert "admin" in r.json()["data"]


# ---------------------------------------------------------------------------
# Users: list / get / me
# ---------------------------------------------------------------------------

def test_list_users_happy_path(client):
    r = client.get("/users", headers=AUTH_FULL)
    assert r.status_code == 200
    data = r.json()["data"]
    assert len(data) == 3
    assert {u["email"] for u in data} == {
        "alice@example.com", "bob@example.com", "carol@example.com"
    }


def test_list_users_role_filter(client):
    r = client.get("/users?role=admin", headers=AUTH_FULL)
    assert r.status_code == 200
    assert all(u["role"] == "admin" for u in r.json()["data"])


def test_list_users_invalid_role_filter(client):
    r = client.get("/users?role=wizard", headers=AUTH_FULL)
    assert r.status_code == 422


def test_list_users_limit_too_high(client):
    r = client.get("/users?limit=999", headers=AUTH_FULL)
    assert r.status_code == 422


def test_get_user_by_id(client):
    r = client.get("/users/u_alice", headers=AUTH_FULL)
    assert r.status_code == 200
    assert r.json()["email"] == "alice@example.com"


def test_get_user_not_found(client):
    r = client.get("/users/does_not_exist", headers=AUTH_FULL)
    assert r.status_code == 404


def test_users_me_alias(client):
    r = client.get("/users/me", headers=AUTH_FULL)
    assert r.status_code == 200
    # token_full is tied to alice's id in seed
    assert r.json()["email"] == "alice@example.com"


# ---------------------------------------------------------------------------
# Users: create / update / delete
# ---------------------------------------------------------------------------

def test_create_user_happy_path(client):
    r = client.post("/users", json={"email": "new@example.com"}, headers=AUTH_FULL)
    assert r.status_code == 201
    assert r.json()["email"] == "new@example.com"
    assert r.json()["role"] == "member"  # default


def test_create_user_rejects_duplicate_email(client):
    r = client.post("/users", json={"email": "alice@example.com"}, headers=AUTH_FULL)
    assert r.status_code == 409


def test_create_user_invalid_email(client):
    r = client.post("/users", json={"email": "not-an-email"}, headers=AUTH_FULL)
    assert r.status_code == 422


def test_create_user_invalid_role(client):
    r = client.post("/users", json={"email": "x@example.com", "role": "overlord"},
                    headers=AUTH_FULL)
    assert r.status_code == 422


def test_update_user(client):
    r = client.patch("/users/u_bob", json={"role": "admin"}, headers=AUTH_FULL)
    assert r.status_code == 200
    assert r.json()["role"] == "admin"


def test_update_user_invalid_role(client):
    r = client.patch("/users/u_bob", json={"role": "wizard"}, headers=AUTH_FULL)
    assert r.status_code == 422


def test_update_user_not_found(client):
    r = client.patch("/users/missing", json={"role": "admin"}, headers=AUTH_FULL)
    assert r.status_code == 404


def test_delete_user_idempotent_gone(client):
    r1 = client.delete("/users/u_bob", headers=AUTH_FULL)
    assert r1.status_code == 200
    r2 = client.delete("/users/u_bob", headers=AUTH_FULL)
    assert r2.status_code == 410
    # GET on deleted user -> 404
    r3 = client.get("/users/u_bob", headers=AUTH_FULL)
    assert r3.status_code == 404


# ---------------------------------------------------------------------------
# Users: state machine
# ---------------------------------------------------------------------------

def test_suspend_active_user(client):
    r = client.post("/users/u_alice/suspend", headers=AUTH_FULL)
    assert r.status_code == 200
    assert r.json()["status"] == "suspended"


def test_suspend_already_suspended_user_is_409(client):
    r = client.post("/users/u_carol/suspend", headers=AUTH_FULL)
    assert r.status_code == 409


def test_restore_suspended_user(client):
    r = client.post("/users/u_carol/restore", headers=AUTH_FULL)
    assert r.status_code == 200
    assert r.json()["status"] == "active"


def test_restore_active_user_is_409(client):
    r = client.post("/users/u_alice/restore", headers=AUTH_FULL)
    assert r.status_code == 409


# ---------------------------------------------------------------------------
# Documents: list / get / create
# ---------------------------------------------------------------------------

def test_list_documents(client):
    r = client.get("/docs", headers=AUTH_FULL)
    assert r.status_code == 200
    assert len(r.json()["data"]) == 3


def test_list_documents_by_state(client):
    r = client.get("/docs?state=draft", headers=AUTH_FULL)
    assert r.status_code == 200
    assert all(d["state"] == "draft" for d in r.json()["data"])


def test_list_user_documents(client):
    r = client.get("/users/u_alice/documents", headers=AUTH_FULL)
    assert r.status_code == 200
    # alice owns two seeded docs
    owners = {d["owner_id"] for d in r.json()["data"]}
    assert owners == {"u_alice"}


def test_list_user_documents_unknown_user(client):
    r = client.get("/users/unknown/documents", headers=AUTH_FULL)
    assert r.status_code == 404


def test_create_document_happy_path(client):
    r = client.post("/docs", json={"title": "new doc"}, headers=AUTH_FULL)
    assert r.status_code == 201
    assert r.json()["state"] == "draft"
    assert r.json()["owner_id"] == "u_alice"  # token_full -> alice


def test_create_document_missing_title(client):
    r = client.post("/docs", json={"body": "no title"}, headers=AUTH_FULL)
    assert r.status_code == 422


def test_get_document(client):
    r = client.get("/docs/d_intro", headers=AUTH_FULL)
    assert r.status_code == 200


def test_get_document_not_found(client):
    r = client.get("/docs/missing", headers=AUTH_FULL)
    assert r.status_code == 404


# ---------------------------------------------------------------------------
# Documents: update / delete / state machine
# ---------------------------------------------------------------------------

def test_update_draft_document(client):
    r = client.patch("/docs/d_specs", json={"title": "updated"}, headers=AUTH_FULL)
    assert r.status_code == 200
    assert r.json()["title"] == "updated"


def test_update_published_document_is_409(client):
    r = client.patch("/docs/d_intro", json={"title": "nope"}, headers=AUTH_FULL)
    assert r.status_code == 409


def test_publish_draft_document(client):
    r = client.post("/docs/d_specs/publish", headers=AUTH_FULL)
    assert r.status_code == 200
    assert r.json()["state"] == "published"


def test_publish_non_draft_is_409(client):
    r = client.post("/docs/d_intro/publish", headers=AUTH_FULL)
    assert r.status_code == 409


def test_archive_published_document(client):
    r = client.post("/docs/d_intro/archive", headers=AUTH_FULL)
    assert r.status_code == 200
    assert r.json()["state"] == "archived"


def test_archive_draft_is_409(client):
    r = client.post("/docs/d_specs/archive", headers=AUTH_FULL)
    assert r.status_code == 409


def test_archive_already_archived_is_409(client):
    r = client.post("/docs/d_old/archive", headers=AUTH_FULL)
    assert r.status_code == 409


def test_delete_document(client):
    r = client.delete("/docs/d_specs", headers=AUTH_FULL)
    assert r.status_code == 200
    r2 = client.get("/docs/d_specs", headers=AUTH_FULL)
    assert r2.status_code == 404


# ---------------------------------------------------------------------------
# /auth/whoami and /auth/scopes
# ---------------------------------------------------------------------------

def test_whoami(client):
    r = client.get("/auth/whoami", headers=AUTH_FULL)
    assert r.status_code == 200
    assert r.json()["email"] == "alice@example.com"


def test_whoami_wrong_scope(client):
    r = client.get("/auth/whoami", headers=AUTH_WRITE)  # write has no users:read
    assert r.status_code == 403


# ---------------------------------------------------------------------------
# Sanity: every endpoint declared in SPEC has a live route
# ---------------------------------------------------------------------------

def test_every_spec_endpoint_has_a_route(client):
    from server.spec import SPEC
    # For each spec endpoint, hit it (unauthenticated) and ensure we do NOT
    # get 404. Anything but 404 (401/403/405/422/etc.) means the path exists.
    for ep in SPEC["endpoints"]:
        path = ep["path"].format(id="u_alice")
        # Substitute remaining placeholders with alice's id or a seeded doc id
        path = path.replace("{id}", "u_alice")
        r = client.request(ep["method"], path)
        assert r.status_code != 404, (
            f"spec endpoint {ep['method']} {ep['path']} has no live route; got 404"
        )