Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions backend/nginx/public_config_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package nginx

import (
"os"
"path"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -12,3 +14,43 @@ func TestPublicConfig_Substitution(t *testing.T) {
assert.NoError(t, nginx.InitConfig())
assertGolden(t, path.Join(outputDir, "nginx.conf"), "nginx.example.com.conf")
}

func TestPublicConfig_IndexRevalidates(t *testing.T) {
config := generatePublicConfig(t)
assert.Equal(t, 2, strings.Count(config, `add_header Cache-Control "no-cache";`))
}

func TestPublicConfig_AssetsImmutable(t *testing.T) {
config := generatePublicConfig(t)
assert.Equal(t, 2, strings.Count(config, `add_header Cache-Control "public, max-age=31536000, immutable";`))
}

func TestPublicConfig_MissingAssetIsNotIndex(t *testing.T) {
config := generatePublicConfig(t)
assert.Equal(t, 2, strings.Count(config, "try_files $uri =404;"))
}

func TestPublicConfig_AssetsKeepSecurityHeaders(t *testing.T) {
config := generatePublicConfig(t)
for _, block := range assetLocations(config) {
assert.Contains(t, block, "Strict-Transport-Security")
assert.Contains(t, block, "Access-Control-Allow-Origin")
}
}

func generatePublicConfig(t *testing.T) string {
t.Helper()
nginx, _, outputDir := newTestNginx(t, "example.com", nil)
assert.NoError(t, nginx.InitConfig())
content, err := os.ReadFile(path.Join(outputDir, "nginx.conf"))
assert.NoError(t, err)
return string(content)
}

func assetLocations(config string) []string {
var blocks []string
for _, part := range strings.Split(config, "location /assets/ {")[1:] {
blocks = append(blocks, strings.SplitN(part, "}", 2)[0])
}
return blocks
}
20 changes: 18 additions & 2 deletions backend/nginx/testdata/nginx.example.com.conf
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ http {
index index.html;
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "no-cache";



location /rest {
proxy_pass http://unix:/var/snap/platform/current/backend.sock: ;
}
Expand All @@ -81,6 +82,13 @@ http {
return 200 "OK";
}

location /assets/ {
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}

location / {
try_files $uri $uri/ /index.html;
}
Expand All @@ -104,8 +112,9 @@ http {

add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "no-cache";



root /snap/platform/current/web/login;

set $upstream http://unix:/var/snap/platform/current/authelia-internal.socket: ;
Expand Down Expand Up @@ -138,6 +147,13 @@ http {
proxy_pass http://unix:/var/snap/platform/current/login.sock: ;
}

location /assets/ {
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}

location / {
try_files $uri /index.html;
}
Expand Down
20 changes: 18 additions & 2 deletions config/nginx/public.conf
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ http {
index index.html;
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "no-cache";



location /rest {
proxy_pass http://unix:/var/snap/platform/current/backend.sock: ;
}
Expand All @@ -81,6 +82,13 @@ http {
return 200 "OK";
}

location /assets/ {
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}

location / {
try_files $uri $uri/ /index.html;
}
Expand All @@ -104,8 +112,9 @@ http {

add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "no-cache";



root /snap/platform/current/web/login;

set $upstream http://unix:/var/snap/platform/current/authelia-internal.socket: ;
Expand Down Expand Up @@ -138,6 +147,13 @@ http {
proxy_pass http://unix:/var/snap/platform/current/login.sock: ;
}

location /assets/ {
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
add_header 'Access-Control-Allow-Origin' '*';
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}

location / {
try_files $uri /index.html;
}
Expand Down
45 changes: 45 additions & 0 deletions test/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,51 @@ def test_platform_rest(device_host):
assert response.status_code == 200


def test_index_is_revalidated(device_host):
response = requests.get('https://{0}'.format(device_host), verify=False)
assert response.status_code == 200
assert response.headers['Cache-Control'] == 'no-cache'


def test_assets_are_immutable(device_host):
index = requests.get('https://{0}'.format(device_host), verify=False)
asset = re.search(r'/assets/[A-Za-z0-9._-]+\.js', index.text).group(0)
response = requests.get('https://{0}{1}'.format(device_host, asset), verify=False)
assert response.status_code == 200
assert response.headers['Cache-Control'] == 'public, max-age=31536000, immutable'


def test_missing_asset_is_not_index(device_host):
response = requests.get('https://{0}/assets/Missing.deadbeef.js'.format(device_host), verify=False)
assert response.status_code == 404


def test_asset_content_type_is_javascript(device_host):
index = requests.get('https://{0}'.format(device_host), verify=False)
asset = re.search(r'/assets/[A-Za-z0-9._-]+\.js', index.text).group(0)
response = requests.get('https://{0}{1}'.format(device_host, asset), verify=False)
assert 'javascript' in response.headers['Content-Type']


def test_auth_index_is_revalidated(full_domain):
response = requests.get('https://auth.{0}'.format(full_domain), verify=False)
assert response.status_code == 200
assert response.headers['Cache-Control'] == 'no-cache'


def test_auth_assets_are_immutable(full_domain):
index = requests.get('https://auth.{0}'.format(full_domain), verify=False)
asset = re.search(r'/assets/[A-Za-z0-9._-]+\.js', index.text).group(0)
response = requests.get('https://auth.{0}{1}'.format(full_domain, asset), verify=False)
assert response.status_code == 200
assert response.headers['Cache-Control'] == 'public, max-age=31536000, immutable'


def test_auth_missing_asset_is_not_index(full_domain):
response = requests.get('https://auth.{0}/assets/Missing.deadbeef.js'.format(full_domain), verify=False)
assert response.status_code == 404


def test_api(device):
time.sleep(10) # start-limit-hit
device.scp_to_device(join(DIR, "api/api.test"), '/', throw=True)
Expand Down
6 changes: 6 additions & 0 deletions web/platform/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import './style/design.css'
import ui from './ui'
import i18n, { detectLocale, setLocale } from './i18n'
import { useThemeStore } from './stores/theme'
import { installStaleAssetsReload, clearStaleAssetsReload } from './util/staleAssets'

async function start () {
if (import.meta.env.VITE_STUB) {
Expand All @@ -18,6 +19,8 @@ async function start () {

setLocale(detectLocale())

installStaleAssetsReload(window, router)

const pinia = createPinia()

createApp(VueApp)
Expand All @@ -28,6 +31,9 @@ async function start () {
.mount('#app')

useThemeStore(pinia).init()

await router.isReady()
clearStaleAssetsReload(window)
}

start()
39 changes: 39 additions & 0 deletions web/platform/src/util/staleAssets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const RELOADED_KEY = 'syncloud-stale-assets-reloaded'

function isStaleAssetError (error) {
const message = (error && error.message) || String(error || '')
return message.includes('Failed to fetch dynamically imported module') ||
message.includes('error loading dynamically imported module') ||
message.includes('Importing a module script failed') ||
message.includes('Unable to preload CSS')
}

function reloadOnce (storage, location) {
if (storage.getItem(RELOADED_KEY)) {
return false
}
storage.setItem(RELOADED_KEY, '1')
location.reload()
return true
}

export function installStaleAssetsReload (window, router) {
const storage = window.sessionStorage

window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
reloadOnce(storage, window.location)
})

router.onError((error) => {
if (isStaleAssetError(error)) {
reloadOnce(storage, window.location)
}
})
}

export function clearStaleAssetsReload (window) {
window.sessionStorage.removeItem(RELOADED_KEY)
}

export { isStaleAssetError, RELOADED_KEY }
83 changes: 83 additions & 0 deletions web/platform/tests/unit/staleAssets.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { installStaleAssetsReload, clearStaleAssetsReload, isStaleAssetError, RELOADED_KEY } from '../../src/util/staleAssets'

function fakeWindow () {
const store = {}
const listeners = {}
return {
reloads: 0,
sessionStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v },
removeItem: (k) => { delete store[k] }
},
location: { reload () { this.reloads += 1 } },
addEventListener: (name, handler) => { listeners[name] = handler },
fire: (name, event) => listeners[name](event)
}
}

function fakeRouter () {
let handler = null
return {
onError: (h) => { handler = h },
fail: (error) => handler(error)
}
}

test('detects stale chunk errors', () => {
expect(isStaleAssetError(new Error('Failed to fetch dynamically imported module: /assets/App.abc.js'))).toBe(true)
expect(isStaleAssetError(new Error('Unable to preload CSS for /assets/App.abc.css'))).toBe(true)
expect(isStaleAssetError(new Error('Request failed with status code 500'))).toBe(false)
})

test('reloads once on router chunk failure', () => {
const window = fakeWindow()
window.location.reloads = 0
const router = fakeRouter()
installStaleAssetsReload(window, router)

router.fail(new Error('Failed to fetch dynamically imported module: /assets/App.abc.js'))
expect(window.location.reloads).toBe(1)

router.fail(new Error('Failed to fetch dynamically imported module: /assets/App.abc.js'))
expect(window.location.reloads).toBe(1)
})

test('ignores unrelated router errors', () => {
const window = fakeWindow()
window.location.reloads = 0
const router = fakeRouter()
installStaleAssetsReload(window, router)

router.fail(new Error('Request failed with status code 500'))
expect(window.location.reloads).toBe(0)
})

test('reloads on vite preload error', () => {
const window = fakeWindow()
window.location.reloads = 0
const router = fakeRouter()
installStaleAssetsReload(window, router)

let prevented = false
window.fire('vite:preloadError', { preventDefault: () => { prevented = true } })

expect(prevented).toBe(true)
expect(window.location.reloads).toBe(1)
})

test('clearing the flag allows a later reload', () => {
const window = fakeWindow()
window.location.reloads = 0
const router = fakeRouter()
installStaleAssetsReload(window, router)

router.fail(new Error('Failed to fetch dynamically imported module: /assets/App.abc.js'))
expect(window.sessionStorage.getItem(RELOADED_KEY)).toBe('1')

clearStaleAssetsReload(window)
expect(window.sessionStorage.getItem(RELOADED_KEY)).toBe(null)

router.fail(new Error('Failed to fetch dynamically imported module: /assets/App.abc.js'))
expect(window.location.reloads).toBe(2)
})