180 lines
11 KiB
Python
180 lines
11 KiB
Python
"""Cloud API regression fixtures are synthetic; no real game/save/account is used."""
|
|
from contextlib import closing
|
|
import hashlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import secrets
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from urllib.error import HTTPError
|
|
from urllib.request import Request, urlopen
|
|
import zipfile
|
|
|
|
ROOT=Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0,str(ROOT/'dm/cloud'))
|
|
import save_api as api
|
|
|
|
TITLE='010093801237C000'
|
|
|
|
|
|
def snapshot(content=b'synthetic save',path='account/progress.bin'):
|
|
record=dict(path=path,kind='file',size=len(content),sha256=hashlib.sha256(content).hexdigest())
|
|
metadata=dict(schema=1,title_id=TITLE,build_id='a'*64,game_version='2.1.0',entries=[record])
|
|
stream=io.BytesIO()
|
|
with zipfile.ZipFile(stream,'w',zipfile.ZIP_DEFLATED) as archive:
|
|
archive.writestr('manifest.json',json.dumps(metadata));archive.writestr(path,content)
|
|
return stream.getvalue()
|
|
|
|
|
|
class SaveStore(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
|
self.store=api.SaveStore(Path(self.temp.name)/'saves.sqlite3');self.payload=snapshot()
|
|
|
|
def test_conflicts_keep_history_and_accounts_are_isolated(self):
|
|
first=self.store.put('alice',TITLE,'default',self.payload,'')
|
|
second=self.store.put('alice',TITLE,'default',snapshot(b'new progress'),first)
|
|
for expected in ('',first):
|
|
with self.assertRaises(api.APIError) as failure:self.store.put('alice',TITLE,'default',self.payload,expected)
|
|
self.assertEqual(failure.exception.status,412)
|
|
self.assertEqual(self.store.get('alice',TITLE,'default')['id'],second)
|
|
self.assertEqual(self.store.get('alice',TITLE,'default',first)['payload'],self.payload)
|
|
self.assertEqual(self.store.list('bob',TITLE),[])
|
|
self.assertEqual({r['revision'] for r in self.store.history('alice',TITLE,'default')},{first,second})
|
|
self.assertEqual(self.store.history('bob',TITLE,'default'),[])
|
|
with self.assertRaises(api.APIError):self.store.get('bob',TITLE,'default',first)
|
|
|
|
def test_precondition_quota_and_title_checks_preserve_existing_data(self):
|
|
first=self.store.put('alice',TITLE,'default',self.payload,'')
|
|
with self.assertRaises(api.APIError) as failure:self.store.put('alice',TITLE,'default',self.payload,None)
|
|
self.assertEqual(failure.exception.status,428)
|
|
with patch.object(api,'MAX_ACCOUNT',len(self.payload)):
|
|
with self.assertRaises(api.APIError):self.store.put('alice',TITLE,'default',self.payload,first)
|
|
with self.assertRaises(api.APIError):self.store.put('alice','0100000000000000','default',self.payload,'')
|
|
self.assertEqual(self.store.get('alice',TITLE,'default')['id'],first)
|
|
|
|
def test_sharing_is_expiring_revocable_and_copies_one_immutable_version(self):
|
|
first=self.store.put('alice',TITLE,'default',self.payload,'')
|
|
share=self.store.share('alice',TITLE,'default',first,60)
|
|
self.store.put('alice',TITLE,'default',snapshot(b'newer save'),first)
|
|
copied=self.store.copy_share('bob',share['token'],TITLE,'from-alice','')
|
|
self.assertEqual(self.store.get('bob',TITLE,'from-alice',copied)['payload'],self.payload)
|
|
with self.assertRaises(api.APIError):self.store.revoke('bob',share['id'])
|
|
with patch.object(api.time,'time',return_value=time.time()+61):
|
|
with self.assertRaises(api.APIError):self.store.copy_share('bob',share['token'],TITLE,'expired','')
|
|
self.store.revoke('alice',share['id'])
|
|
with self.assertRaises(api.APIError):self.store.copy_share('bob',share['token'],TITLE,'revoked','')
|
|
self.assertEqual(self.store.get('bob',TITLE,'from-alice',copied)['payload'],self.payload)
|
|
|
|
def test_bad_archives_and_paths_are_rejected(self):
|
|
for path in ('../outside','account/../outside','account/prod.keys','account/game.nsp','romfs/game.bin','account/link/../../outside','account\\bad.bin'):
|
|
with self.subTest(path=path),self.assertRaises(api.APIError):api.save_manifest(snapshot(path=path))
|
|
with self.assertRaises(api.APIError):api.save_manifest(b'not a zip')
|
|
with self.assertRaises(api.APIError):self.store.put('alice',TITLE,'../outside',self.payload,'')
|
|
# The JSON's hash does not authenticate different compressed bytes.
|
|
raw=io.BytesIO(self.payload);out=io.BytesIO()
|
|
with zipfile.ZipFile(raw) as source,zipfile.ZipFile(out,'w') as target:
|
|
for entry in source.infolist():
|
|
content=source.read(entry)
|
|
if entry.filename=='account/progress.bin':content=b'changed save'
|
|
target.writestr(entry,content)
|
|
with self.assertRaises(api.APIError):api.save_manifest(out.getvalue())
|
|
|
|
def test_concurrent_writers_cannot_silently_overwrite(self):
|
|
first=self.store.put('alice',TITLE,'default',self.payload,'')
|
|
barrier=threading.Barrier(2);outcomes=[]
|
|
def write():
|
|
barrier.wait()
|
|
try:outcomes.append(self.store.put('alice',TITLE,'default',snapshot(secrets.token_bytes(20)),first))
|
|
except api.APIError as error:outcomes.append(error.status)
|
|
threads=[threading.Thread(target=write) for _ in range(2)]
|
|
for thread in threads:thread.start()
|
|
for thread in threads:thread.join(timeout=10)
|
|
self.assertEqual(sum(isinstance(value,str) for value in outcomes),1)
|
|
self.assertIn(412,outcomes)
|
|
|
|
def test_real_http_requires_auth_and_preconditions(self):
|
|
from wsgiref.simple_server import make_server, WSGIRequestHandler
|
|
class Quiet(WSGIRequestHandler):
|
|
def log_message(self,format,*args):pass
|
|
def verify(token):
|
|
if token!='test-alice':raise api.APIError(401,'invalid test identity')
|
|
return 'alice'
|
|
server=make_server('127.0.0.1',0,api.SaveAPI(self.store,verify),handler_class=Quiet)
|
|
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
|
try:
|
|
base=f'http://127.0.0.1:{server.server_port}'
|
|
with closing(urlopen(base+'/health')) as response:self.assertEqual(response.status,200)
|
|
with self.assertRaises(HTTPError) as failure:urlopen(base+'/v1/me')
|
|
self.assertEqual(failure.exception.code,401)
|
|
endpoint=base+f'/v1/games/{TITLE}/saves/default/snapshot'
|
|
headers={'Authorization':'Bearer test-alice','If-None-Match':'*'}
|
|
with closing(urlopen(Request(endpoint,data=self.payload,headers=headers,method='PUT'))) as response:
|
|
self.assertEqual(response.status,201);revision=json.load(response)['revision']
|
|
with self.assertRaises(HTTPError) as failure:urlopen(Request(endpoint,data=self.payload,headers=headers,method='PUT'))
|
|
self.assertEqual(failure.exception.code,412)
|
|
with closing(urlopen(Request(endpoint,headers={'Authorization':'Bearer test-alice'}))) as response:
|
|
self.assertEqual(response.read(),self.payload);self.assertEqual(response.headers['ETag'],'"'+revision+'"')
|
|
finally:server.shutdown();server.server_close();thread.join(timeout=5)
|
|
|
|
|
|
@unittest.skipUnless(importlib.util.find_spec('jwt'),'install dm/cloud/requirements.txt for real JWT checks')
|
|
class Identity(unittest.TestCase):
|
|
def test_real_signed_token_validates_signature_issuer_audience_and_expiry(self):
|
|
import jwt
|
|
from jwt.algorithms import RSAAlgorithm
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
|
issuer='https://identity.example.invalid';audience='nxnative-save-api'
|
|
verifier=api.oidc_verifier(issuer,audience,issuer+'/jwks')
|
|
jwk=jwt.PyJWK.from_json(RSAAlgorithm.to_jwk(key.public_key()))
|
|
claims=dict(iss=issuer,aud=audience,sub='alice',iat=int(time.time()),exp=int(time.time())+300)
|
|
with patch.object(jwt.PyJWKClient,'get_signing_key_from_jwt',return_value=jwk):
|
|
valid=jwt.encode(claims,key,algorithm='RS256')
|
|
self.assertEqual(verifier(valid),hashlib.sha256((issuer+'\0alice').encode()).hexdigest())
|
|
for changes in ({'iss':'https://other.invalid'},{'aud':'other-app'},{'exp':int(time.time())-120}):
|
|
with self.subTest(changes=changes),self.assertRaises(api.APIError):verifier(jwt.encode(dict(claims,**changes),key,algorithm='RS256'))
|
|
with self.assertRaises(api.APIError):verifier(jwt.encode(claims,'synthetic-test-key-with-32-bytes-minimum',algorithm='HS256'))
|
|
other=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
|
with self.assertRaises(api.APIError):verifier(jwt.encode(claims,other,algorithm='RS256'))
|
|
with self.assertRaises(ValueError):api.oidc_verifier(issuer,audience,'https://other.invalid/jwks')
|
|
|
|
|
|
class PortableBackup(unittest.TestCase):
|
|
def test_committed_wal_history_and_shares_survive_relocation(self):
|
|
from scripts.backup_save_api import backup
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root=Path(temporary);source=root/'source.sqlite3';store=api.SaveStore(source)
|
|
# Keep a connection open so committed records can remain in the WAL.
|
|
with closing(store.connect()) as keeper:
|
|
keeper.execute('PRAGMA journal_mode=WAL')
|
|
payload=snapshot();first=store.put('alice',TITLE,'default',payload,'')
|
|
share=store.share('alice',TITLE,'default',first,60)
|
|
second=store.put('alice',TITLE,'default',snapshot(b'new'),first)
|
|
target=backup(source,root/'moved.sqlite3')
|
|
moved=api.SaveStore(target)
|
|
self.assertEqual(moved.get('alice',TITLE,'default')['id'],second)
|
|
self.assertEqual(len(moved.history('alice',TITLE,'default')),2)
|
|
copied=moved.copy_share('bob',share['token'],TITLE,'shared','')
|
|
self.assertEqual(moved.get('bob',TITLE,'shared',copied)['payload'],payload)
|
|
self.assertEqual(store.list('bob',TITLE),[])
|
|
self.assertEqual(target.stat().st_mode&0o777,0o600)
|
|
with self.assertRaises(FileExistsError):backup(source,target)
|
|
|
|
def test_missing_and_unrelated_sources_are_not_created_or_published(self):
|
|
from scripts.backup_save_api import backup
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root=Path(temporary);missing=root/'missing.sqlite3';target=root/'backup.sqlite3'
|
|
with self.assertRaises(FileNotFoundError):backup(missing,target)
|
|
self.assertFalse(missing.exists());self.assertFalse(target.exists())
|
|
source=root/'unrelated.sqlite3'
|
|
with closing(api.sqlite3.connect(source)) as db:db.execute('CREATE TABLE other(value)')
|
|
with self.assertRaises(ValueError):backup(source,target)
|
|
self.assertFalse(target.exists())
|