| 1 | #!/usr/bin/env python3 |
| 2 | import argparse |
| 3 | import base64 |
| 4 | from concurrent.futures import ThreadPoolExecutor |
| 5 | import copy |
| 6 | import http.cookiejar |
| 7 | import hashlib |
| 8 | import importlib |
| 9 | import json |
| 10 | import math |
| 11 | from pathlib import Path |
| 12 | import sqlite3 |
| 13 | import ssl |
| 14 | import subprocess |
| 15 | import sys |
| 16 | import time |
| 17 | import urllib.error |
| 18 | import urllib.parse |
| 19 | import urllib.request |
| 20 | import uuid |
| 21 | |
| 22 | |
| 23 | class NoRedirect(urllib.request.HTTPRedirectHandler): |
| 24 | def redirect_request(self, request, fp, code, message, headers, newurl): |
| 25 | return None |
| 26 | |
| 27 | |
| 28 | def 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 | |
| 465 | if __name__ == '__main__': |
| 466 | main() |