# Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 from datetime import datetime, timedelta, timezone from http import HTTPStatus from os.path import basename, join as opj from unittest.mock import patch from freezegun import freeze_time from urllib3.util import parse_url import odoo from odoo.tests import new_test_user, tagged, RecordCapturer from odoo.tools import config, file_open, image_process from odoo.tools.misc import submap from .test_common import TestHttpBase, HTTP_DATETIME_FORMAT class TestHttpStaticCommon(TestHttpBase): @classmethod def setUpClass(cls): super().setUpClass() cls.classPatch(config, 'options', {**config.options, 'x_sendfile': False}) with file_open('test_http/static/src/img/gizeh.png', 'rb') as file: cls.gizeh_data = file.read() with file_open('web/static/img/placeholder.png', 'rb') as file: cls.placeholder_data = file.read() def assertDownload( self, url, headers, assert_status_code, assert_headers, assert_content=None ): res = self.db_url_open(url, headers=headers) res.raise_for_status() self.assertEqual(res.status_code, assert_status_code) self.assertEqual(submap(res.headers, assert_headers), assert_headers) if assert_content: self.assertEqual(res.content, assert_content) return res def assertDownloadGizeh(self, url, x_sendfile=None, assert_filename='gizeh.png'): headers = { 'Content-Length': '814', 'Content-Type': 'image/png', 'Content-Disposition': f'inline; filename={assert_filename}' } if x_sendfile: sha = basename(x_sendfile) headers['X-Sendfile'] = x_sendfile headers['X-Accel-Redirect'] = f'/web/filestore/{self.cr.dbname}/{sha[:2]}/{sha}' headers['Content-Length'] = '0' return self.assertDownload(url, {}, 200, headers, b'' if x_sendfile else self.gizeh_data) def assertDownloadPlaceholder(self, url): headers = { 'Content-Length': '6078', 'Content-Type': 'image/png', 'Content-Disposition': 'inline; filename=placeholder.png' } return self.assertDownload(url, {}, 200, headers, self.placeholder_data) @tagged('post_install', '-at_install') class TestHttpStatic(TestHttpStaticCommon): def setUp(self): super().setUp() self.authenticate('demo', 'demo') def test_static00_static(self): with self.subTest(x_sendfile=False): res = self.assertDownloadGizeh('/test_http/static/src/img/gizeh.png') self.assertCacheControl(res, 'public, max-age=604800') with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): # The file is outside of the filestore, X-Sendfile disabled res = self.assertDownloadGizeh('/test_http/static/src/img/gizeh.png', x_sendfile=False) self.assertCacheControl(res, 'public, max-age=604800') def test_static01_debug_assets(self): session = self.authenticate(None, None) session.debug = 'assets' res = self.assertDownloadGizeh('/test_http/static/src/img/gizeh.png') self.assertCacheControl(res, 'no-cache, max-age=0') def test_static02_not_found(self): session = self.authenticate(None, None) session.db = None res = self.nodb_url_open("/test_http/static/i-dont-exist") self.assertEqual(res.status_code, 404) def test_static03_attachment_fallback(self): attachment = self.env.ref('test_http.gizeh_png') with self.subTest(x_sendfile=False): self.assertDownloadGizeh(attachment.url) with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): self.assertDownloadGizeh( attachment.url, x_sendfile=opj(config.filestore(self.env.cr.dbname), attachment.store_fname), ) def test_static04_web_content(self): attachment = self.env.ref('test_http.gizeh_png') with self.subTest(x_sendfile=False): self.assertDownloadGizeh('/web/content/test_http.gizeh_png') with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): self.assertDownloadGizeh( '/web/content/test_http.gizeh_png', x_sendfile=opj(config.filestore(self.env.cr.dbname), attachment.store_fname), ) def test_static05_web_image(self): attachment = self.env.ref('test_http.gizeh_png') with self.subTest(x_sendfile=False): self.assertDownloadGizeh('/web/image/test_http.gizeh_png') with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): self.assertDownloadGizeh( '/web/image/test_http.gizeh_png', x_sendfile=opj(config.filestore(self.env.cr.dbname), attachment.store_fname), ) def test_static06_attachment_internal_url(self): with self.subTest(x_sendfile=False): self.assertDownloadGizeh('/web/image/test_http.gizeh_url') with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): # The file is outside of the filestore, X-Sendfile disabled self.assertDownloadGizeh('/web/image/test_http.gizeh_url', x_sendfile=False) def test_static07_attachment_external_url(self): res = self.db_url_open('/web/content/test_http.rickroll') res.raise_for_status() self.assertEqual(res.status_code, 301) self.assertURLEqual( res.headers.get('Location'), 'https://www.youtube.com/watch?v=dQw4w9WgXcQ') def test_static08_binary_field(self): earth = self.env.ref('test_http.earth') attachment = self.env['ir.attachment'].search([ ('res_model', '=', 'test_http.stargate'), ('res_id', '=', earth.id), ('res_field', '=', 'glyph_attach') ], limit=1) attachment_path = opj(config.filestore(self.env.cr.dbname), attachment.store_fname) for field, is_attachment in ( ('glyph_attach', True), ('glyph_inline', False), ('glyph_related', True), ('glyph_compute', False), ): with self.subTest(x_sendfile=False): self.assertDownloadGizeh( f'/web/content/test_http.earth?field={field}', assert_filename='Earth.png' ) if is_attachment: with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): self.assertDownloadGizeh( f'/web/content/test_http.earth?field={field}', x_sendfile=is_attachment and attachment_path, assert_filename='Earth.png' ) def test_static10_filename(self): with self.subTest("record name"): self.assertDownloadGizeh( '/web/content/test_http.gizeh_png', assert_filename='gizeh.png', ) with self.subTest("forced name"): self.assertDownloadGizeh( '/web/content/test_http.gizeh_png?filename=pyramid.png', assert_filename='pyramid.png', ) with self.subTest("filename field"): self.assertDownloadGizeh( '/web/content/test_http.earth?field=glyph_inline&filename_field=address', assert_filename='sq5Abt.png', ) def test_static11_bad_filenames(self): with self.subTest("missing record name"): gizeh = self.env.ref('test_http.gizeh_png') realname = gizeh.name gizeh.name = '' try: self.assertDownloadGizeh( '/web/content/test_http.gizeh_png', assert_filename=f'ir_attachment-{gizeh.id}-raw.png' ) finally: gizeh.name = realname with self.subTest("missing file extension"): self.assertDownloadGizeh( '/web/content/test_http.gizeh_png?filename=pyramid', assert_filename='pyramid.png', ) with self.subTest("wrong file extension"): res = self.assertDownloadGizeh( '/web/content/test_http.gizeh_png?filename=pyramid.jpg', assert_filename='pyramid.jpg', ) self.assertEqual(res.headers['Content-Type'], 'image/png') with self.subTest("dotted name"): res = self.assertDownloadGizeh( '/web/content/test_http.gizeh_png?filename=pyramid.of.gizeh', assert_filename='pyramid.of.gizeh.png', ) def test_static12_not_found_to_placeholder(self): with self.subTest(x_sendfile=False): self.assertDownloadPlaceholder('/web/image/idontexist') with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): # The file is outside of the filestore, X-Sendfile disabled self.assertDownloadPlaceholder('/web/image/idontexist') def test_static13_empty_to_placeholder(self): att = self.env['ir.attachment'].create([{ 'name': 'empty.png', 'type': 'binary', 'raw': b'', # this is not a valid png file, whatever 'public': True }]) with self.subTest(x_sendfile=False): self.assertDownloadPlaceholder(f'/web/image/{att.id}') with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): # The file is outside of the filestore, X-Sendfile disabled self.assertDownloadPlaceholder(f'/web/image/{att.id}') def test_static14_download_not_found(self): res = self.url_open('/web/image/idontexist?download=True') self.assertEqual(res.status_code, 404) def test_static15_range(self): self.assertDownload( url='/web/content/test_http.gizeh_png', headers={'Range': 'bytes=100-199'}, assert_status_code=206, assert_headers={ 'Content-Length': '100', 'Content-Range': 'bytes 100-199/814', 'Content-Type': 'image/png', 'Content-Disposition': 'inline; filename=gizeh.png', }, assert_content=self.gizeh_data[100:200] ) def test_static16_public_access_rights(self): self.authenticate(None, None) default_user = self.env.ref('base.default_user') with self.subTest('model access rights'): res = self.url_open(f'/web/content/res.users/{default_user.id}/image_128') self.assertEqual(res.status_code, 404) with self.subTest('attachment + field access rights'): res = self.url_open('/web/content/test_http.pegasus?field=picture') self.assertEqual(res.status_code, 404) with self.subTest('related attachment + field access rights'): res = self.url_open('/web/content/test_http.earth?field=galaxy_picture') self.assertEqual(res.status_code, 404) def test_static17_content_missing_checksum(self): att = self.env['ir.attachment'].create({ 'name': 'testhttp.txt', 'db_datas': 'some data', 'public': True, }) self.assertFalse(att.checksum) self.assertDownload( url=f'/web/content/{att.id}', headers={}, assert_status_code=200, assert_headers={ 'Content-Length': '9', 'Content-Type': 'text/plain; charset=utf-8', 'Content-Disposition': 'inline; filename=testhttp.txt', }, assert_content=b'some data', ) def test_static18_image_missing_checksum(self): with file_open('test_http/static/src/img/gizeh.png', 'rb') as file: att = self.env['ir.attachment'].create({ 'name': 'gizeh.png', 'db_datas': file.read(), 'mimetype': 'image/png', 'public': True, }) self.assertFalse(att.checksum) self.assertDownloadGizeh(f'/web/image/{att.id}') def test_static19_fallback_redirection_loop(self): bad_path = '/test_http/static/idontexist.png' self.assertRaises(FileNotFoundError, file_open, bad_path[1:]) self.env['ir.attachment'].create({ 'name': 'idontexist.png', 'mimetype': 'image/png', 'url': bad_path, 'public': True, }) res = self.url_open(bad_path, allow_redirects=False) location = parse_url(res.headers.get('Location', '')) self.assertNotEqual(location.path, bad_path, "loop detected") self.assertEqual(res.status_code, 404) def test_static20_web_assets(self): attachment = self.env['ir.attachment'].search([ ('url', 'like', '%/web.assets_frontend_minimal.min.js') ], limit=1) x_sendfile = opj(config.filestore(self.env.cr.dbname), attachment.store_fname) x_accel_redirect = f'/web/filestore/{self.env.cr.dbname}/{attachment.store_fname}' with self.subTest(x_sendfile=False): self.assertDownload( attachment.url, headers={}, assert_status_code=200, assert_headers={ 'Content-Length': str(attachment.file_size), 'Content-Type': 'application/javascript; charset=utf-8', 'Content-Disposition': 'inline; filename=web.assets_frontend_minimal.min.js', }, assert_content=attachment.raw ) with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): self.assertDownload( attachment.url, headers={}, assert_status_code=200, assert_headers={ 'X-Sendfile': x_sendfile, 'X-Accel-Redirect': x_accel_redirect, 'Content-Length': '0', 'Content-Type': 'application/javascript; charset=utf-8', 'Content-Disposition': 'inline; filename=web.assets_frontend_minimal.min.js', }, ) def test_static21_download_false(self): self.assertDownloadGizeh('/web/content/test_http.gizeh_png?download=0') self.assertDownloadGizeh('/web/image/test_http.gizeh_png?download=0') def test_static21_web_assets(self): attachment = self.env['ir.attachment'].search([ ('url', 'like', '%/web.assets_frontend_minimal.min.js') ], limit=1) x_sendfile = opj(config.filestore(self.env.cr.dbname), attachment.store_fname) x_accel_redirect = f'/web/filestore/{self.env.cr.dbname}/{attachment.store_fname}' with self.subTest(x_sendfile=False): self.assertDownload( attachment.url, headers={}, assert_status_code=200, assert_headers={ 'Content-Length': str(attachment.file_size), 'Content-Type': 'application/javascript; charset=utf-8', 'Content-Disposition': 'inline; filename=web.assets_frontend_minimal.min.js', }, assert_content=attachment.raw ) with self.subTest(x_sendfile=True), \ patch.object(config, 'options', {**config.options, 'x_sendfile': True}): self.assertDownload( attachment.url, headers={}, assert_status_code=200, assert_headers={ 'X-Sendfile': x_sendfile, 'X-Accel-Redirect': x_accel_redirect, 'Content-Length': '0', 'Content-Type': 'application/javascript; charset=utf-8', 'Content-Disposition': 'inline; filename=web.assets_frontend_minimal.min.js', }, ) def test_static22_image_field_csp(self): test_user = new_test_user(self.env, "test user") env = self.env(user=test_user) self.authenticate('test user', 'test user') earth = env.ref('test_http.earth') data = base64.b64encode(b'