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