"""Invented local website records only. No browser or original SHS runtime."""
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from html.parser import HTMLParser
import copy
import hashlib
import json
import shutil
import sqlite3
import uuid
import zipfile
import xml.etree.ElementTree as ET
import pytest
from fastapi.testclient import TestClient
from backend import recovery
from backend.app import create_app
from backend.store import DEMO_BUSINESSES, Store, initial_site
from backend.site_models import normalise, Section, Page, Service, asset_ids
from backend.documents import setup as setup_documents
from test_boundaries import app, client, write, job, quote, invoice, portal, png, ORIGIN
from test_documents import make_legacy

STATIC=Path(__file__).parents[1]/'frontend/dist'


def read(c):return c.get('/api/site').json()
def save(c,draft,version=None,key=None):
    return write(c,'/api/site',{'version':version or read(c)['version'],'content':draft},method='put',key=key)
def publish(c,key=None):return write(c,'/api/site/publish',{'version':read(c)['version']},key=key)
def photo(c):
    r=c.post('/api/assets',content=png());assert r.status_code==200,r.text;return r.json()['id']
def page(slug='custom',visible=True,image=None):
    return Page(id='page-'+slug,slug=slug,title='Custom '+slug,nav_label='Menu '+slug,visible=visible,sections=[Section(id='text',kind='text',heading='Heading '+slug,body='Private or published text '+slug,image_id=image)]).model_dump()
def service(slug='shelving',visible=True,image=None):
    return Service(title='Bespoke shelving',description='Shelving made for this room.',slug=slug,visible=visible,image_id=image).model_dump()


def test_two_business_content_routes_images_and_settings_are_independent(app):
    a,b=client(app),client(app,1);anon=TestClient(app,headers=ORIGIN)
    for c,biz in [(a,'oak'),(b,'harbour')]:
        aid=photo(c);d=read(c)['draft'];d['business_name']=biz+' Demo Studio';d['address']=biz+' Test address';d['colour']='#ffffff' if biz=='oak' else '#000000';d['logo_id']=aid
        d['pages'].append(page(biz));d['services']=[service(biz,image=aid)];d['document_settings']['company_number']=biz+'-fictional'
        assert save(c,d).status_code==200;assert publish(c).status_code==200
        own=DEMO_BUSINESSES[0 if biz=='oak' else 1]['slug'];other=DEMO_BUSINESSES[1 if biz=='oak' else 0]['slug']
        assert anon.get('/api/public/'+own+'/pages/'+biz).status_code==200
        assert anon.get('/api/public/'+other+'/pages/'+biz).status_code==404
        assert anon.get('/api/public/'+own+'/pages/services/'+biz).status_code==200
        assert anon.get('/api/public/'+other+'/assets/'+aid).status_code==404
        assert c.get('/api/session').json()['name']==d['business_name']
        public=anon.get('/api/public/'+own).json();assert 'document_settings' not in public
        assert public['address']==d['address'] and public['logo_id']==aid
    assert {x['id'] for x in a.get('/api/assets').json()}.isdisjoint({x['id'] for x in b.get('/api/assets').json()})


def test_hidden_page_service_section_gallery_and_old_publication_assets_are_private(app):
    c=client(app);anon=TestClient(app,headers=ORIGIN);ids=[photo(c) for _ in range(5)];d=read(c)['draft']
    d['pages'].append(page('secret',False,ids[0]));d['services'].append(service('secret-service',False,ids[1]))
    d['pages'][0]['sections'].append(Section(id='secret-section',kind='image',visible=False,body='SECRET SECTION',image_id=ids[2]).model_dump())
    d['gallery']=[{'asset_id':ids[3],'caption':'SECRET GALLERY','alt':'Hidden','visible':False}]
    d['pages'].append(page('visible',True,ids[4]));assert save(c,d).status_code==200
    for aid in ids:assert anon.get('/api/public/oak-and-vale/assets/'+aid).status_code==404
    assert publish(c).status_code==200
    public=anon.get('/api/public/oak-and-vale');assert 'SECRET' not in public.text and 'secret' not in public.text
    for aid in ids[:4]:assert anon.get('/api/public/oak-and-vale/assets/'+aid).status_code==404
    assert anon.get('/api/public/oak-and-vale/assets/'+ids[4]).status_code==200
    assert anon.get('/api/public/oak-and-vale/pages/secret').status_code==404
    d=read(c)['draft'];d['pages'][-1]['visible']=False;assert save(c,d).status_code==200;assert publish(c).status_code==200
    assert anon.get('/api/public/oak-and-vale/assets/'+ids[4]).status_code==404
    assert c.get('/api/assets/'+ids[4]).status_code==200
    assert anon.get('/api/assets').status_code==401 and anon.get('/api/site').status_code==401


def test_menu_hidden_is_still_public_and_order_and_sections_are_retained(app):
    c=client(app);d=read(c)['draft'];p=page('no-menu');p['in_navigation']=False;p['in_footer']=True;p['sections']+=[Section(id='extra',kind='faq',items=[{'question':'When?','answer':'By arrangement.'}]).model_dump()];d['pages'].insert(0,p);d['services'].reverse()
    assert save(c,d).status_code==200;assert publish(c).status_code==200
    result=c.get('/api/public/oak-and-vale').json();assert result['pages'][0]['slug']=='no-menu' and not result['pages'][0]['in_navigation'];assert result['services'][0]==d['services'][0]
    assert c.get('/api/public/oak-and-vale/pages/no-menu').json()['sections'][1]['items'][0]['answer']=='By arrangement.'


@pytest.mark.parametrize('slug',['api','workspace','portal','preview','admin','documents','home','../private','a/b','Upper','bad--slug','bad%2fpath','robots.txt','site','assets'])
def test_unsafe_or_reserved_page_slugs_are_rejected(app,slug):
    c=client(app);d=read(c)['draft'];p=page();p['slug']=slug;d['pages'].append(p)
    assert save(c,d).status_code==422;assert read(c)['version']==1


@pytest.mark.parametrize('link',['javascript:alert(1)','data:text/html,evil','//evil.example','http://example.test','https://name:password@example.test','/api/secrets','/admin','/../workspace','/%61pi','https://example.test/\nhello','https://example.test:8443','https://example.test/\\x'])
def test_unsafe_links_are_rejected_without_saving(app,link):
    c=client(app);d=read(c)['draft'];d['pages'][0]['sections'][0].update(link=link,link_label='Click')
    assert save(c,d).status_code==422 and read(c)['version']==1


def test_missing_internal_target_blocks_atomic_publication_but_keeps_draft(app):
    c=client(app);before=read(c);d=before['draft'];d['pages'][0]['sections'][0]['link']='/missing-page'
    assert save(c,d).status_code==200;assert publish(c).status_code==422
    current=read(c);assert current['published']==before['published'] and current['history']==before['history'] and current['version']==2
    d['pages'].append(page('missing-page'));assert save(c,d).status_code==200;assert publish(c).status_code==200


def test_identity_settings_and_historical_logos_are_snapshotted(app):
    c=client(app);other=client(app,1);old_logo=photo(c);d=read(c)['draft'];d.update(business_name='First Demo',address='Old address',logo_id=old_logo);d['document_settings'].update(company_number='Demo-123',vat_number='Demo-456',footer='Original owner wording',payment_terms='Original terms')
    assert save(c,d).status_code==200;assert publish(c).status_code==200
    j=job(c);q=quote(c,j);i=invoice(c,q);p,_=portal(app,c,j)
    old=c.get('/api/documents/invoice/'+i['id']).json()['snapshot']
    d=read(c)['draft'];d.update(business_name='Second Demo',address='New address',logo_id=None);d['document_settings']['footer']='New wording';assert save(c,d).status_code==200
    assert c.get('/api/session').json()['name']=='First Demo';assert publish(c).status_code==200
    assert c.get('/api/session').json()['name']=='Second Demo'
    assert c.get('/api/documents/invoice/'+i['id']).json()['snapshot']==old
    newq=quote(c,j);new=c.get('/api/documents/quote/'+newq['id']).json()['snapshot']['business'];assert new['business_name']=='Second Demo' and new['document_settings']['footer']=='New wording'
    assert 'Original owner wording' not in c.get('/api/public/oak-and-vale').text
    assert c.get('/api/public/oak-and-vale/assets/'+old_logo).status_code==404
    assert p.get('/api/portal/oak-and-vale/documents/invoice/'+i['id']+'/logo').status_code==200
    assert other.get('/api/documents/invoice/'+i['id']+'/logo').status_code==404
    assert TestClient(app,headers=ORIGIN).get('/api/documents/invoice/'+i['id']+'/logo').status_code==401
    otherjob=job(c);otherportal,_=portal(app,c,otherjob);assert otherportal.get('/api/portal/oak-and-vale/documents/invoice/'+i['id']+'/logo').status_code==404
    assert write(c,'/api/invoices/'+i['id']+'/payments',{'amount_pence':2000}).status_code==200
    assert c.get('/api/documents/invoice/'+i['id']).json()['balance_pence']==8000


def test_site_mutations_require_staff_csrf_origin_and_image_ownership(app):
    c=client(app);other=client(app,1);d=read(c)['draft'];d['pages'][0]['meta']['image_id']=photo(other)
    assert save(c,d).status_code==404
    d=read(c)['draft'];j=job(c);p,_=portal(app,c,j)
    assert save(p,d,1).status_code==401
    assert c.put('/api/site',json={'version':1,'content':d},headers={'x-csrf-token':''}).status_code==403
    assert c.put('/api/site',json={'version':1,'content':d},headers={'origin':'https://wrong.example'}).status_code==403
    assert c.put('/api/site',json={'version':1,'content':d}).status_code==400


def test_stale_concurrent_and_replayed_saves_publish_restore_are_consistent(app):
    a,b=client(app),client(app);da=read(a)['draft'];db=copy.deepcopy(da);da['tagline']='First competing draft';db['tagline']='Second competing draft'
    with ThreadPoolExecutor(max_workers=2) as pool:
        codes=list(pool.map(lambda pair:save(pair[0],pair[1],1).status_code,[(a,da),(b,db)]))
    assert sorted(codes)==[200,409]
    version=read(a)['version'];key=str(uuid.uuid4());payload={'version':version}
    one=write(a,'/api/site/publish',payload,key=key);two=write(a,'/api/site/publish',payload,key=key)
    assert one.status_code==two.status_code==200 and one.json()==two.json();assert len(read(a)['history'])==2
    version=read(a)['version'];payload={'version':version,'revision_id':1};key=str(uuid.uuid4())
    assert write(a,'/api/site/restore',payload,key=key).status_code==200
    assert write(a,'/api/site/restore',payload,key=key).status_code==200
    assert read(a)['version']==version+1 and read(a)['published']['tagline']!=read(a)['draft']['tagline']
    key=str(uuid.uuid4());version=read(a)['version'];d=read(a)['draft'];assert save(a,d,version,key).status_code==200;assert save(a,d,version,key).status_code==200;assert read(a)['version']==version+1


class Head(HTMLParser):
    def __init__(self):super().__init__();self.tags=[]
    def handle_starttag(self,tag,attrs):self.tags.append((tag,dict(attrs)))


def test_unknown_and_unpublished_website_routes_render_safe_html_with_real_404(tmp_path):
    app=create_app(tmp_path/'missing-pages',STATIC);owner=client(app)
    draft=read(owner)['draft'];draft['pages'].append(page('secret',False));draft['pages'][-1]['meta']['title']='PRIVATE PAGE TITLE'
    draft['services'].append(service('secret-service',False));draft['services'][-1]['meta']['description']='PRIVATE SERVICE DESCRIPTION'
    assert save(owner,draft).status_code==200 and publish(owner).status_code==200
    anon=TestClient(app,headers=ORIGIN)
    for path in ['missing','secret','services/secret-service']:
        response=anon.get('/site/oak-and-vale/'+path)
        assert response.status_code==404 and response.headers['content-type'].startswith('text/html')
        assert '<div id="root"></div>' in response.text
        assert '<title>Page not found · Oak &amp; Vale</title>' in response.text
        assert 'noindex, nofollow' in response.text and response.headers['x-robots-tag']=='noindex, nofollow'
        assert 'PRIVATE PAGE TITLE' not in response.text and 'PRIVATE SERVICE DESCRIPTION' not in response.text
        api_response=anon.get('/api/public/oak-and-vale/pages/'+path)
        assert api_response.status_code==404 and api_response.json()=={'detail':'This page is unavailable.'}


def test_server_metadata_xml_noindex_host_and_route_boundaries(tmp_path):
    app=create_app(tmp_path/'html',STATIC);c=client(app);d=read(c)['draft'];aid=photo(c);d['meta'].update(title='Global <title> & business',description='Description with "quotes" & angles <x>',image_id=aid)
    p=page('custom');p['meta']['title']='"><script>alert(1)</script>';d['pages'].append(p);hidden=page('hidden',False);d['pages'].append(hidden);d['services']=[service('joinery'),service('hidden-service',False)]
    assert save(c,d).status_code==200;assert publish(c).status_code==200
    anon=TestClient(app,headers=ORIGIN)
    home=anon.get('/site/oak-and-vale');assert home.status_code==200 and 'Global &lt;title&gt; &amp; business' in home.text
    html=anon.get('/site/oak-and-vale/custom',headers={'x-forwarded-host':'evil.example','x-forwarded-proto':'https'});parsed=Head();parsed.feed(html.text)
    assert len([t for t in parsed.tags if t[0]=='script'])==1
    metas={a.get('property',a.get('name')):a.get('content') for t,a in parsed.tags if t=='meta'}
    assert metas['og:title']==p['meta']['title'];assert metas['og:url']=='http://testserver/site/oak-and-vale/custom';assert metas['og:image']=='http://testserver/api/public/oak-and-vale/assets/'+aid
    assert metas['robots']=='noindex, nofollow' and html.headers['x-robots-tag']=='noindex, nofollow'
    assert 'evil.example' not in html.text and '<script>alert' not in html.text
    assert anon.get('/site/oak-and-vale/hidden').status_code==404 and anon.get('/site/oak-and-vale/services/hidden-service').status_code==404
    assert anon.get('/site/oak-and-vale/services/joinery').status_code==200
    assert anon.get('/site/unknown').status_code==404
    sitemap=anon.get('/site/oak-and-vale/sitemap.xml');paths=[x.text for x in ET.fromstring(sitemap.text).findall('.//{*}loc')]
    assert 'http://testserver/site/oak-and-vale/custom' in paths and not any('hidden' in x for x in paths)
    assert anon.get('/robots.txt').text.endswith('Disallow: /\n') and anon.get('/site/oak-and-vale/robots.txt').status_code==200
    assert 'alert(1)' not in anon.get('/preview/oak-and-vale/custom').text
    assert anon.get('/site/oak-and-vale',headers={'host':'testserver:evil'}).status_code==400
    assert anon.get('/site/oak-and-vale',headers={'host':'evil.example'}).status_code==400


def test_invalid_structures_metadata_and_duplicate_routes_fail_atomically(app):
    c=client(app);base=read(c)['draft']
    edits=[]
    for field,value in [('colour','red; background:url(https://evil.example)'),('content_version',999),('email','x\nInjected@a.example')]:
        d=copy.deepcopy(base);d[field]=value;edits.append(d)
    d=copy.deepcopy(base);d['pages'].append(copy.deepcopy(d['pages'][0]));edits.append(d)
    d=copy.deepcopy(base);d['services'].append(copy.deepcopy(d['services'][0]));edits.append(d)
    d=copy.deepcopy(base);d['pages']=[p for p in d['pages'] if p['id']!='contact'];edits.append(d)
    d=copy.deepcopy(base);d['pages'][-1]['sections'][0]['visible']=False;edits.append(d)
    d=copy.deepcopy(base);d['pages'][0]['meta']['image_id']='https://evil.example/image';edits.append(d)
    d=copy.deepcopy(base);d['script']='alert(1)';edits.append(d)
    for d in edits:assert save(c,d,1).status_code==422
    assert read(c)['draft']==base and read(c)['version']==1


def legacy_v05(root):
    dataset,records=make_legacy(root)
    for b in DEMO_BUSINESSES:
        with sqlite3.connect(root/b['id']/'business.sqlite3') as db:
            db.row_factory=sqlite3.Row;setup_documents(db)
            published=initial_site(b);published['tagline']='Published old '+b['id'];draft=copy.deepcopy(published);draft['tagline']='Unsaved to public '+b['id'];draft['about']='Private old story '+b['id']
            db.execute('UPDATE site SET draft=?,published=?',(json.dumps(draft),json.dumps(published)))
            db.execute('UPDATE site_history SET content=?',(json.dumps(published),))
    return dataset,records


def test_forward_upgrade_preserves_distinct_old_drafts_publications_history_and_documents(tmp_path):
    root=tmp_path/'v05';_,records=legacy_v05(root);before={}
    for bid in ('oak','harbour'):
        with sqlite3.connect(root/bid/'business.sqlite3') as db:before[bid]=db.execute('SELECT snapshot FROM invoices').fetchone()[0]
    app=create_app(root)
    for n,bid in enumerate(('oak','harbour')):
        c=client(app,n);site=read(c)
        assert site['draft']['tagline']=='Unsaved to public '+bid and site['published']['tagline']=='Published old '+bid
        assert site['draft']['about']=='Private old story '+bid and len(site['draft']['pages'])==5
        assert json.dumps(c.get('/api/documents/invoice/'+records[bid]['invoice']).json()['snapshot'])==before[bid]
        assert len(site['history'])==1 and len([a for a in site['audit'] if a['action']=='site.structured_upgrade'])==1
    Store(root);assert len([a for a in read(client(app))['audit'] if a['action']=='site.structured_upgrade'])==1


def test_strict_v2_recovery_upgrades_fresh_copy_and_new_v3_can_roundtrip(tmp_path):
    root=tmp_path/'v05';dataset,records=legacy_v05(root)
    meta={'format':recovery.FORMAT,'version':2,'prototype_version':'0.5.0','created_at':'2026-10-27T12:00:00+00:00','dataset_id':dataset,'auth_policy':recovery.LEGACY_AUTH_POLICY,'businesses':[{'id':b['id'],'slug':b['slug'],'name':b['name'],'enabled':1} for b in DEMO_BUSINESSES],'domains':[],'files':{}}
    archive=tmp_path/'legacy-v2.zip'
    with zipfile.ZipFile(archive,'w') as z:
        for b in DEMO_BUSINESSES:
            p=root/b['id']/'business.sqlite3'
            with sqlite3.connect(p) as db:db.execute('UPDATE customers SET portal_hash=NULL')
            data=p.read_bytes();name='businesses/'+b['id']+'/business.sqlite3';meta['files'][name]={'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest(),'business_id':b['id'],'kind':'database'};z.writestr(name,data)
        z.writestr('manifest.json',json.dumps(meta))
    sha=hashlib.sha256(archive.read_bytes()).hexdigest();assert recovery.verify(archive,sha)['valid'];target=tmp_path/'fresh';recovery.restore(archive,sha,target,confirm_fresh=True)
    with sqlite3.connect(root/'oak/business.sqlite3') as db:assert 'content_version' not in json.loads(db.execute('SELECT draft FROM site').fetchone()[0])
    with sqlite3.connect(target/'oak/business.sqlite3') as db:
        assert json.loads(db.execute('SELECT draft FROM site').fetchone()[0])['content_version']==2
        assert db.execute('SELECT amount_pence FROM payments').fetchone()[0]==2500
    new=recovery.backup(target,tmp_path/'v3.zip');assert recovery.verify(new['archive'],new['sha256'])['valid']
    with zipfile.ZipFile(new['archive']) as z:assert json.loads(z.read('manifest.json'))['version']==recovery.VERSION


def test_recovery_restores_two_structured_sites_hidden_assets_and_sealed_document_branding(app,tmp_path):
    originals={}
    for n,bid in enumerate(('oak','harbour')):
        c=client(app,n);aid=photo(c);d=read(c)['draft'];d['business_name']=bid+' Test studio';d['logo_id']=aid;d['pages'].append(page('new-page'));d['services']=[service('fit-out')];d['document_settings']['footer']='Test wording '+bid;assert save(c,d).status_code==200;assert publish(c).status_code==200
        j=job(c);q=quote(c,j);inv=invoice(c,q);d=read(c)['draft'];hidden=photo(c);d['pages'].append(page('hidden',False,hidden));d['logo_id']=None;assert save(c,d).status_code==200
        originals[bid]={'site':read(c),'invoice':c.get('/api/documents/invoice/'+inv['id']).json(),'logo':aid,'hidden':hidden}
    backup=recovery.backup(app.state.store.root,tmp_path/'website.zip');shutil.rmtree(app.state.store.root);target=tmp_path/'restore';recovery.restore(backup['archive'],backup['sha256'],target,confirm_fresh=True);store=Store(target)
    from backend.documents import get_document
    for bid,data in originals.items():
        with store.tenant(bid) as db:
            saved=json.loads(db.execute('SELECT draft FROM site').fetchone()[0]);public=json.loads(db.execute('SELECT published FROM site').fetchone()[0]);assert saved==data['site']['draft'] and public==data['site']['published'];assert get_document(db,'invoice',data['invoice']['id'])==data['invoice']
        assert (target/bid/'assets'/(data['logo']+'.png')).exists() and (target/bid/'assets'/(data['hidden']+'.png')).exists()
    proof={'scope':'invented local data','source_deleted_before_restore':True,'businesses':list(originals),'drafts_publications_and_document_snapshots_equal':True,'hidden_and_historical_assets_retained':True,'supported_formats':[1,2,3],'new_format':3,'browser_verified':False}
    (tmp_path/'website-recovery-proof.json').write_text(json.dumps(proof,indent=2))


def test_concurrent_save_and_publish_do_not_mix_publication_content(app):
    a,b=client(app),client(app);d=read(a)['draft'];d['tagline']='Race edit only'
    with ThreadPoolExecutor(max_workers=2) as pool:
        a_future=pool.submit(save,a,d,1);b_future=pool.submit(write,b,'/api/site/publish',{'version':1});results=[a_future.result().status_code,b_future.result().status_code]
    assert sorted(results)==[200,409];state=read(a)
    if results[0]==200:assert state['draft']['tagline']=='Race edit only' and state['published']['tagline']!='Race edit only' and len(state['history'])==1
    else:assert state['draft']==state['published'] and len(state['history'])==2


def test_saved_quote_brand_is_retained_when_converted_after_business_edit(app):
    c=client(app);j=job(c);q=quote(c,j);old=c.get('/api/documents/quote/'+q['id']).json()['snapshot']['business'];assert write(c,'/api/quotes/'+q['id']+'/accept',{'version':1}).status_code==200
    d=read(c)['draft'];d['business_name']='Changed after acceptance';assert save(c,d).status_code==200;assert publish(c).status_code==200
    r=write(c,'/api/quotes/'+q['id']+'/invoice',{'version':2});assert r.status_code==200
    assert c.get('/api/documents/invoice/'+r.json()['id']).json()['snapshot']['business']==old


def test_recovery_rejects_unsafe_or_wrong_version_website_json(app,tmp_path):
    for mutation in ('unsafe_link','unknown_version','foreign_image'):
        c=client(app);original=read(c)['draft'];bad=copy.deepcopy(original)
        if mutation=='unsafe_link':bad['pages'][0]['sections'][0]['link']='javascript:alert(1)'
        elif mutation=='unknown_version':bad['content_version']=99
        else:bad['pages'][0]['meta']['image_id']='f'*32
        with app.state.store.tenant('oak') as db:db.execute('UPDATE site SET draft=?',(json.dumps(bad),))
        with pytest.raises(ValueError):recovery.backup(app.state.store.root,tmp_path/(mutation+'.zip'))
        assert not (tmp_path/(mutation+'.zip')).exists()
        with app.state.store.tenant('oak') as db:db.execute('UPDATE site SET draft=?',(json.dumps(original),))


def test_page_and_site_sitemap_exclusion_is_explicit_and_enquiry_stays_tenant_scoped(tmp_path):
    app=create_app(tmp_path/'demo',STATIC);c=client(app);d=read(c)['draft'];d['pages'][1]['meta']['noindex']=True;d['enquiry_success_message']='Fictional custom confirmation.';assert save(c,d).status_code==200;assert publish(c).status_code==200
    anon=TestClient(app,headers=ORIGIN);text=anon.get('/site/oak-and-vale/sitemap.xml').text;assert '<loc>http://testserver/site/oak-and-vale/services</loc>' not in text
    payload={'name':'Local Enquiry','email':'local@customer.example','service':'Custom service request','message':'Invented website enquiry.'};r=write(anon,'/api/public/oak-and-vale/enquiries',payload);assert r.status_code==200
    assert len(c.get('/api/workspace').json()['jobs'])==1 and client(app,1).get('/api/workspace').json()['jobs']==[]
    d=read(c)['draft'];d['meta']['noindex']=True;assert save(c,d).status_code==200;assert publish(c).status_code==200
    assert len(ET.fromstring(anon.get('/site/oak-and-vale/sitemap.xml').text))==0
