1#!/usr/bin/env python3
2import argparse
3import base64
4from concurrent.futures import ThreadPoolExecutor
5import copy
6import http.cookiejar
7import hashlib
8import importlib
9import json
10import math
11from pathlib import Path
12import sqlite3
13import ssl
14import subprocess
15import sys
16import time
17import urllib.error
18import urllib.parse
19import urllib.request
20import uuid
21
22
23class NoRedirect(urllib.request.HTTPRedirectHandler):
24 def redirect_request(self, request, fp, code, message, headers, newurl):
25 return None
26
27
28def main():
29 parser = argparse.ArgumentParser()
30 parser.add_argument('--url', required=True)
31 parser.add_argument('--proof-file', type=Path, required=True)
32 parser.add_argument('--data-dir', type=Path, required=True)
33 parser.add_argument('--restart-unit', required=True)
34 parser.add_argument('--shale-origin', required=True)
35 parser.add_argument('--shale-database', type=Path, required=True)
36 parser.add_argument('--shale-container', required=True)
37 parser.add_argument('--output', type=Path)
38 args = parser.parse_args()
39 if args.output:
40 args.output.unlink(missing_ok=True)
41 repo = Path(__file__).resolve().parent.parent
42 sys.path.insert(0, str(repo / 'service/keycloak'))
43 from api import Keycloak, LoopbackHTTPS
44 Page = importlib.import_module('dashboard-shale-test').Page
45 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
46 context = ssl.create_default_context(cafile='/var/lib/studio/ca-bundle.crt')
47 origin = 'https://globe.studio.test'
48 proof = args.proof_file.read_text().strip()
49 marker = 'shale-link-' + uuid.uuid4().hex
50 accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!'),
51 ('ai', uuid.uuid4().hex + 'A1!')]
52 ids = []
53 sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts}
54
55 class TLS(urllib.request.HTTPSHandler):
56 def https_open(self, request):
57 parsed = urllib.parse.urlsplit(request.full_url)
58 assert parsed.netloc in ('keycloak.studio.test', urllib.parse.urlsplit(args.shale_origin).netloc)
59 return self.do_open(LoopbackHTTPS, request, context=context)
60
61 def browser():
62 cookies = http.cookiejar.CookieJar()
63 return urllib.request.build_opener(TLS(), NoRedirect(), urllib.request.HTTPCookieProcessor(cookies)), cookies
64
65 def request(client, url, method='GET', body=None, headers=None, status=None):
66 try:
67 response = client.open(urllib.request.Request(url, method=method, data=body, headers=headers or {}), timeout=45)
68 except urllib.error.HTTPError as error:
69 response = error
70 with response:
71 value = response.read(4 * 1024 * 1024 + 1)
72 assert len(value) <= 4 * 1024 * 1024
73 if status is not None:
74 assert response.status == status, (urllib.parse.urlsplit(url).path, response.status, value[:300])
75 return response.status, response.headers, value.decode()
76
77 def api(actor, method='GET', path='/api/mcp', status=200, body=None):
78 _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method,
79 body=json.dumps(body).encode() if body is not None else None,
80 headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor,
81 'Cookie': '__Host-snow-session=' + sessions[actor],
82 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status)
83 return json.loads(body) if body and status < 400 else body or None
84
85 def records(prefix):
86 path = args.data_dir / 'connections.sqlite'
87 with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as db:
88 return {key: json.loads(value) for key, value in db.execute('SELECT key,value FROM records WHERE substr(key,1,?)=?', (len(prefix), prefix))}
89
90 def session(index):
91 return records('shale-session:').get('shale-session:' + ids[index])
92
93 def session_count(index):
94 with sqlite3.connect(args.shale_database.as_uri() + '?mode=ro', uri=True) as db:
95 return db.execute('SELECT count(*) FROM sessions s JOIN users u ON s.user=u.id WHERE u.snowflake=?', (ids[index],)).fetchone()[0]
96
97 def start(index, pending=None):
98 target = api(accounts[index][0], 'POST', '/api/mcp/shale', body={'request': pending} if pending else {})['redirect']
99 assert target.startswith(args.shale_origin + '/-/studio-mcp/')
100 client, cookies = browser()
101 _, headers, _ = request(client, target, status=302)
102 link_cookies = [cookie for cookie in cookies if cookie.name == 'studio_mcp_shale_link']
103 assert len(link_cookies) == 1
104 cookie = link_cookies[0]
105 assert not cookie.domain_specified and cookie.secure and cookie.path == '/-/callback'
106 assert cookie.has_nonstandard_attr('HttpOnly') and cookie.get_nonstandard_attr('SameSite') == 'Lax'
107 assert headers.get('Referrer-Policy') == 'no-referrer'
108 assert headers.get('Cache-Control') == 'no-store'
109 request(browser()[0], target, status=410)
110 return client, cookies, headers['Location'], target
111
112 def authorize(client, authorization, index):
113 status, headers, body = request(client, authorization, status=200)
114 form = next(form for form in Page(body).forms if any(field.get('name') == 'password' for field in form['fields']))
115 target = urllib.parse.urljoin(authorization, form['action'])
116 assert urllib.parse.urlsplit(target).hostname == 'keycloak.studio.test'
117 fields = {field['name']: field.get('value', '') for field in form['fields'] if field.get('name')}
118 fields.update(username=accounts[index][0], password=accounts[index][1])
119 status, headers, body = request(client, target, 'POST', urllib.parse.urlencode(fields).encode(),
120 {'Content-Type': 'application/x-www-form-urlencoded', 'Origin': 'https://keycloak.studio.test'})
121 for _ in range(6):
122 assert status in (302, 303), status
123 target = urllib.parse.urljoin(target, headers['Location'])
124 parts = urllib.parse.urlsplit(target)
125 if parts.netloc == urllib.parse.urlsplit(args.shale_origin).netloc:
126 assert parts.path == '/-/callback' and 'code' in dict(urllib.parse.parse_qsl(parts.query))
127 return target
128 assert parts.netloc == 'keycloak.studio.test'
129 status, headers, body = request(client, target)
130 raise AssertionError('too many sign-in redirects')
131
132 def finish(client, target, status=303, pending=None):
133 _, headers, _ = request(client, target, status=status)
134 assert headers.get('Cache-Control') == 'no-store'
135 assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', []))
136 assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', []))
137 if status == 303:
138 assert headers['Location'] == origin + ('/connect/' + pending if pending else '/mcp/settings/shale')
139
140 def backend_session(value, status):
141 return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status)
142
143 def link(index, pending=None):
144 client, cookies, authorization, _ = start(index, pending)
145 target = authorize(client, authorization, index)
146 captured = http.cookiejar.CookieJar()
147 for cookie in cookies:
148 captured.set_cookie(copy.copy(cookie))
149 finish(client, target, pending=pending)
150 replay = urllib.request.build_opener(TLS(), NoRedirect(), urllib.request.HTTPCookieProcessor(captured))
151 finish(replay, target, 410)
152 result = session(index)
153 assert result and result['origin'] == args.shale_origin + '/'
154 assert result['linkedAt'] > 0 and len(result['session']) > 20
155 overview = api(accounts[index][0])['shale']
156 assert overview is not None and set(overview) == {'linkedAt'}, overview
157 assert math.isclose(overview['linkedAt'], result['linkedAt'], rel_tol=0, abs_tol=1e-6), overview
158 assert session_count(index) == 1
159 backend_session(result, 200)
160 return result
161
162 def public(path, method='GET', body=None, status=200, token=None, form=False):
163 headers = {'Host': 'globe.studio.test', 'Content-Type': 'application/x-www-form-urlencoded' if form else 'application/json'}
164 if token:
165 headers.update({'Authorization': 'Bearer ' + token, 'Accept': 'application/json, text/event-stream',
166 'MCP-Protocol-Version': '2025-11-25'})
167 encoded = urllib.parse.urlencode(body).encode() if form else json.dumps(body).encode() if body is not None else None
168 _, headers, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method, encoded, headers, status)
169 return (json.loads(body) if body and headers.get('Content-Type', '').startswith('application/json') else body or None), headers
170
171 def pending_request(client, index, scope):
172 verifier = uuid.uuid4().hex + uuid.uuid4().hex
173 challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=')
174 _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code',
175 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256',
176 'code_challenge': challenge, 'resource': origin + '/mcp/shale', 'scope': scope, 'state': marker}), status=302)
177 pending = urllib.parse.urlsplit(headers['Location']).path.removeprefix('/connect/')
178 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)
179 assert details['client'] == marker
180 api(accounts[1 if index == 0 else 0][0], path='/api/mcp/consent/' + pending, status=403)
181 return pending, verifier, details
182
183 def consent(client, index, repository, scope):
184 pending, verifier, details = pending_request(client, index, scope)
185 available = {r['id'] for r in details['resources']}
186 assert details['linked'] and (repository == 'all' or repository in available), details
187 if index == 0:
188 assert {'alpha', 'beta'} <= available, details
189 path = '/api/mcp/consent/' + pending
190 api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']})
191 api(accounts[index][0], 'POST', path, 403 if len(available) >= 2 else 400, {'resources': [repository, repository]})
192 result = api(accounts[index][0], 'POST', path, body={'resources': 'all' if repository == 'all' else [repository]})
193 query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query)
194 assert query['state'] == [marker]
195 tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'],
196 'redirect_uri': client['redirect_uris'][0], 'code_verifier': verifier, 'code': query['code'][0],
197 'resource': origin + '/mcp/shale'}, form=True)
198 return tokens
199
200 def rpc(token, method, params=None, error=False):
201 value, _ = public('/mcp/shale', 'POST', {'jsonrpc': '2.0', 'id': 1, 'method': method,
202 **({'params': params} if params is not None else {})}, token=token)
203 assert 'error' not in value, value
204 value = value['result']
205 assert bool(value.get('isError')) == error, value
206 return value if error or 'structuredContent' not in value else value['structuredContent']
207
208 def call(token, tool, fields=None, error=False):
209 return rpc(token, 'tools/call', {'name': tool, 'arguments': fields or {}}, error=error)
210
211 def backend(index, suffix, fields=None):
212 headers = {'Cookie': 'SessionID=' + session(index)['session']}
213 if fields is not None:
214 headers.update({'Origin': args.shale_origin, 'Referer': args.shale_origin + suffix,
215 'Content-Type': 'application/x-www-form-urlencoded'})
216 return request(browser()[0], args.shale_origin + suffix, 'POST' if fields is not None else 'GET',
217 urllib.parse.urlencode(fields).encode() if fields is not None else None, headers)
218
219 def submit_backend(index, path, changes):
220 status, _, body = backend(index, path)
221 assert status == 200, (path, status, body[:700])
222 forms = [form for form in Page(body).forms if changes.keys() <= {field.get('name') for field in form['fields']}
223 and ('t' not in changes or any(field.get('name') == 't' and field.get('value') == changes['t'] for field in form['fields']))]
224 assert len(forms) == 1, path
225 form = forms[0]
226 assert urllib.parse.urljoin(args.shale_origin + path, form.get('action', '')) == args.shale_origin + path
227 fields = {field['name']: field.get('value', '') for field in form['fields']
228 if field.get('name') and field.get('type') == 'hidden'}
229 fields.update(timezone='UTC', tzoffset='+00:00', **changes)
230 return backend(index, path, fields)
231
232 def repository(index, name):
233 status, headers, _ = submit_backend(1, '/-/new', {'name': name, 'description': 'Owned Shale MCP fixture', 'access': 'private'})
234 assert status == 303, status
235 target = urllib.parse.urlsplit(urllib.parse.urljoin(args.shale_origin, headers['Location']))
236 assert target.netloc == urllib.parse.urlsplit(args.shale_origin).netloc
237 assert target.path.rstrip('/') == '/' + name, target.path
238 if index == 0:
239 with sqlite3.connect(args.shale_database) as db:
240 identity = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[index],)).fetchone()[0]
241 assert db.execute('UPDATE repositories SET owner=? WHERE name=?', (identity, name)).rowcount == 1
242
243 for name, _ in accounts:
244 assert not keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'), 'Fixture account already exists: ' + name
245 try:
246 for name, credential in accounts:
247 keycloak.request('/admin/realms/master/users', 'POST', {'username': name, 'firstName': name,
248 'lastName': 'Fixture', 'email': name + '@fixture.invalid', 'emailVerified': True, 'enabled': True,
249 'credentials': [{'type': 'password', 'value': credential, 'temporary': False}]})
250 found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true')
251 assert len(found) == 1
252 ids.append(found[0]['id'])
253 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:
254 profile = {key: found[0].get(key) for key in ['username', 'firstName', 'lastName', 'email', 'emailVerified', 'enabled']}
255 profile['requiredActions'] = []
256 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (ids[-1], json.dumps(profile)))
257 stamp = int(time.time())
258 db.execute('INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)',
259 (hashlib.sha256(sessions[name].encode()).hexdigest(), ids[-1], 'dashboard', stamp + 3600, '127.0.0.1', stamp, stamp, stamp))
260 assert all(api(name)['shale'] is None for name, _ in accounts)
261 api(accounts[0][0], 'POST', '/api/mcp/shale', status=200)
262 old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']
263 latest_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']
264 request(browser()[0], old_target, status=410)
265 with sqlite3.connect(args.data_dir / 'connections.sqlite') as db:
266 db.execute("UPDATE records SET expires=1 WHERE substr(key,1,11)='shale-link:'")
267 request(browser()[0], latest_target, status=410)
268 client, cookies, authorization, _ = start(0)
269 request(client, args.shale_origin + '/-/callback?state=other&code=fixture', status=403)
270 request(browser()[0], args.url + '/oauth/shale/link/' + 'a' * 43, headers={'Host': 'globe.studio.test'}, status=403)
271 client, cookies, authorization, _ = start(0)
272 finish(client, authorize(client, authorization, 1), 403)
273 assert session(0) is None and session(1) is None and session_count(1) == 0
274 oauth_client, _ = public('/oauth/register', 'POST', {'client_name': marker,
275 'redirect_uris': ['http://127.0.0.1:29999/shale-callback'], 'token_endpoint_auth_method': 'none'}, status=201)
276 pending, _, details = pending_request(oauth_client, 0, 'shale:read')
277 assert not details['linked'] and not details['resources']
278 api(accounts[1][0], 'POST', '/api/mcp/shale', 403, {'request': pending})
279 first = link(0, pending)
280 api(accounts[0][0], 'POST', '/api/mcp/consent/' + pending, body={'deny': True})
281 assert api(accounts[1][0])['shale'] is None
282 second = link(1)
283 assert first['session'] != second['session']
284 new_first = link(0)
285 assert new_first['session'] != first['session']
286 backend_session(first, 303)
287 backend_session(second, 200)
288 assert session_count(0) == 1
289 for index, name in [(0, 'alpha'), (0, 'beta'), (1, 'foreign')]:
290 repository(index, name)
291 readonly = consent(oauth_client, 0, 'alpha', 'shale:read offline_access')
292 writing = consent(oauth_client, 0, 'alpha', 'shale:read shale:write offline_access')
293 other = consent(oauth_client, 1, 'foreign', 'shale:read shale:write')
294 read = readonly['access_token']
295 write = writing['access_token']
296 initialized = rpc(read, 'initialize', {'protocolVersion': '2025-11-25', 'capabilities': {},
297 'clientInfo': {'name': marker, 'version': '1'}})
298 assert initialized['capabilities']['tools'] == {}
299 assert 'Assisted-by:' in initialized['instructions'] and 'model' in initialized['instructions']
300 listed = rpc(read, 'tools/list')
301 assert listed["ttlMs"] == 0 and listed["cacheScope"] == "private", listed
302 tools = listed["tools"]
303 assert {tool['name'] for tool in tools} == {'list_repositories', 'list_issues', 'get_issue', 'create_issue',
304 'comment_issue', 'set_issue_status', 'set_issue_title', 'list_labels', 'create_label', 'update_label', 'delete_label', 'set_issue_labels'}
305 assert all('model' in tool['inputSchema']['required'] for tool in tools
306 if not tool['annotations']['readOnlyHint'])
307 create_schema = next(tool['inputSchema'] for tool in tools if tool['name'] == 'create_issue')
308 assert create_schema['properties']['status']['enum'] == ['todo', 'triage']
309 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)
310 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}
311 grant = next(value for value in records('grant:').values() if value['scopes'] == ['shale:read', 'offline_access'] and value['user'] == ids[0])
312 endpoint = '/api/mcp/connections/' + grant['id']
313 details = api(accounts[0][0], path=endpoint)
314 assert details['linked'] and details['selected'] == ['alpha']
315 assert {'alpha', 'beta'} <= {resource['id'] for resource in details['resources']}
316 api(accounts[1][0], path=endpoint, status=404)
317 api(accounts[1][0], 'POST', endpoint, 404, {'resources': ['foreign']})
318 api(accounts[0][0], 'POST', endpoint, 403, {'resources': ['foreign']})
319 api(accounts[0][0], 'POST', endpoint, 403, {'resources': ['alpha', 'alpha']})
320 api(accounts[0][0], 'POST', endpoint, 400, {'resources': []})
321 api(accounts[0][0], 'POST', endpoint, 204, {'resources': ['beta']})
322 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'beta'}
323 call(read, 'list_issues', {'repository': 'alpha'}, error=True)
324 call(read, 'create_issue', {'repository': 'beta', 'title': 'REFUSED'}, error=True)
325 api(accounts[0][0], 'POST', endpoint, 204, {'resources': 'all'})
326 assert api(accounts[0][0], path=endpoint)['selected'] == 'all'
327 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha', 'beta'}
328 call(read, 'list_issues', {'repository': 'foreign'}, error=True)
329 call(read, 'create_issue', {'repository': 'beta', 'title': 'REFUSED'}, error=True)
330 nested = 'userscripts/nested-fixture'
331 repository(0, nested)
332 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha', 'beta', nested}
333 assert not call(read, 'list_issues', {'repository': nested})['issues']
334 api(accounts[0][0], 'POST', endpoint, 204, {'resources': [nested]})
335 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {nested}
336 call(read, 'list_issues', {'repository': 'beta'}, error=True)
337 api(accounts[0][0], 'POST', endpoint, 204, {'resources': ['alpha']})
338
339 assert {repo['id'] for repo in call(other['access_token'], 'list_repositories')['repositories']} == {'foreign'}
340 all_repositories = consent(oauth_client, 0, 'all', 'shale:read')
341 assert {repo['id'] for repo in call(all_repositories['access_token'], 'list_repositories')['repositories']} == {'alpha', 'beta', nested}
342 assert not call(read, 'list_issues', {'repository': 'alpha'})['issues']
343 for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'),
344 (write, 'alpha/../foreign'), (write, 'https://other.invalid')]:
345 call(token, 'list_issues', {'repository': name}, error=True)
346 call(read, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
347 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED', 'url': 'https://other.invalid'}, error=True)
348 public('/mcp/observability', 'POST', {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, token=read, status=401)
349 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
350 title = 'MCP <angle> & Unicode ☃'
351 status, _, _ = submit_backend(0, '/alpha/issues/new', {'title': title,
352 'description': 'Owned body <script>fixture</script> & Unicode ☃'})
353 assert status == 303
354 created = call(read, 'get_issue', {'repository': 'alpha', 'id': 1})['issue']
355 assert created['title'] == title and created['id'] == 1
356 assert len(created['comments']) == 1 and 'Unicode ☃' in created['comments'][0]['text']
357 assert '<script>' not in json.dumps(created)
358 listed = call(read, 'list_issues', {'repository': 'alpha', 'q': 'is:open'})['issues'][0]
359 assert listed['title'] == title and listed['status'] == created['status']
360 rejected = call(read, 'list_issues', {'repository': 'alpha', 'q': 'Unicode'}, error=True)
361 assert 'filter syntax' in rejected['content'][0]['text'], rejected
362 issue_fields = {'repository': 'alpha', 'id': created['id']}
363 status, _, _ = submit_backend(0, '/alpha/issues/labels', {'name': 'Owned ☃', 'description': 'Fixture label', 'color': '#ff40ff'})
364 assert status == 303
365 status, _, _ = submit_backend(0, '/alpha/issues/1', {'t': 'labels', 'labels': '1'})
366 assert status == 303
367 changed = call(read, 'get_issue', issue_fields)['issue']
368 assert changed['labels'] == ['Owned ☃'], changed
369 with sqlite3.connect(args.shale_database) as db:
370 rows = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[1],)).fetchall()
371 assert len(rows) == 1
372 original_owner = db.execute("SELECT owner FROM repositories WHERE name='alpha'").fetchone()[0]
373 db.execute("UPDATE repositories SET owner=?,access_git_webui='private',access_issues='private' WHERE name='alpha'", (rows[0][0],))
374 try:
375 call(read, 'get_issue', issue_fields, error=True)
376 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
377 finally:
378 with sqlite3.connect(args.shale_database) as db:
379 db.execute("UPDATE repositories SET owner=?,access_git_webui='public',access_issues='public' WHERE name='alpha'", (original_owner,))
380 with sqlite3.connect(args.shale_database) as db:
381 assert db.execute("SELECT count(*) FROM issues WHERE title='REFUSED'").fetchone()[0] == 0
382 subprocess.run(['systemctl', 'restart', args.restart_unit], check=True, capture_output=True, timeout=30)
383 assert session(0) == new_first and session(1) == second
384 assert math.isclose(api(accounts[0][0])['shale']['linkedAt'], new_first['linkedAt'], rel_tol=0, abs_tol=1e-6)
385 backend_session(new_first, 200)
386 assert call(read, 'get_issue', issue_fields)['issue'] == changed
387 backend(0, '/-/logout')
388 call(read, 'get_issue', issue_fields, error=True)
389 new_first = link(0)
390 assert call(read, 'get_issue', issue_fields)['issue'] == changed
391 client, cookies, authorization, _ = start(0)
392 target = authorize(client, authorization, 0)
393 subprocess.run(['podman', 'pause', args.shale_container], check=True, capture_output=True)
394 try:
395 with ThreadPoolExecutor(max_workers=2) as pool:
396 callback = pool.submit(finish, client, target, 410)
397 deadline = time.monotonic() + 10
398 while not any(record.get('phase') == 'processing' for record in records('shale-link:').values()):
399 assert time.monotonic() < deadline, 'callback did not reach backend'
400 time.sleep(.05)
401 unlink = pool.submit(api, accounts[0][0], 'DELETE', '/api/mcp/shale', 204)
402 deadline = time.monotonic() + 10
403 while session(0) is not None or records('shale-link:'):
404 assert time.monotonic() < deadline, 'unlink did not cancel the callback'
405 time.sleep(.05)
406 subprocess.run(['podman', 'unpause', args.shale_container], check=True, capture_output=True)
407 callback.result(timeout=30)
408 unlink.result(timeout=30)
409 finally:
410 subprocess.run(['podman', 'unpause', args.shale_container], capture_output=True)
411 assert session(0) is None and session_count(0) == 0
412 assert api(accounts[0][0])['shale'] is None and session(1) == second
413 backend_session(second, 200)
414 public('/mcp/shale', token=read, status=401)
415 public('/mcp/shale', token=write, status=401)
416 public('/oauth/token', 'POST', {'grant_type': 'refresh_token', 'client_id': oauth_client['client_id'],
417 'refresh_token': readonly['refresh_token'], 'resource': origin + '/mcp/shale'}, form=True, status=400)
418 api(accounts[1][0], 'DELETE', '/api/mcp/shale', 204)
419 assert session(1) is None and session_count(1) == 0
420 backend_session(second, 303)
421 api(accounts[2][0], 'DELETE', '/api/mcp/shale', 204)
422 assert session(2) is None
423 assert not records('shale-link:') and not records('shale-session:')
424 result = {'two_user_oidc_linking': True, 'same_realm_ordinary_users': True, 'host_only_callback_cookie': True,
425 'single_use_state_and_expiry': True, 'account_mismatch_refused_and_session_removed': True,
426 'cross_user_session_isolation': True, 'backend_session_not_exposed_to_browser': True,
427 'relink_revokes_previous_backend_session': True, 'restart_preserves_credentials': True,
428 'unlink_cancels_inflight_callback': True, 'unlink_revokes_backend_session': True,
429 'oauth_link_returns_to_consent': True, 'sdk_catalog_and_repository_grants': True,
430 'cross_repository_and_cross_user_tools_refused': True, 'read_only_and_audience_enforced': True,
431 'native_issue_read': True, 'unicode_and_rendered_text': True,
432 'native_issue_labels_read': True,
433 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,
434 'all_repository_approval_and_dynamic_access': True, 'nested_repository_paths': True, 'existing_token_resource_edits': True,
435 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}
436 finally:
437 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
438 cleanup_errors = []
439 for (name, _), identity in zip(accounts, ids):
440 try:
441 api(name, 'DELETE', '/api/mcp/shale', 204)
442 except Exception as error:
443 cleanup_errors.append(error)
444 try:
445 keycloak.request('/admin/realms/master/users/' + identity, 'DELETE')
446 except Exception as error:
447 cleanup_errors.append(error)
448 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:
449 db.execute('PRAGMA foreign_keys=ON')
450 for identity in ids:
451 for table in ['sessions', 'credentials', 'memberships']:
452 db.execute(f'DELETE FROM {table} WHERE user_id=?', (identity,))
453 db.execute('DELETE FROM users WHERE id=?', (identity,))
454 if cleanup_errors:
455 raise cleanup_errors[0]
456 result['owned_users_and_credentials_removed'] = True
457 if args.output:
458 args.output.write_text(json.dumps(result, indent=2) + '\n')
459 print(json.dumps(result), flush=True)
460
461
462if __name__ == '__main__':
463 main()