Adaline Simonian 23a3862ca0
test: introduce snapshot-based smoke tests
- Adds a new test suite with Docker-based smoke tests for all locales.
  Can be run using the ./smoketest.sh script.
- Replaces all calls to Math.random() with a new helper that returns 0.5
  in snapshot testing mode, ensuring deterministic snapshots.
- Similarly replaces all calls to new Date() and Date.now() with new
  helpers that return a fixed date in snapshot testing mode.
- Replaces checks against NODE_ENV with APP_ENV, to ensure that the
  bundles can be built with Nuxt for testing without losing code that
  would otherwise be stripped out by production optimizations.
- Adds a database init script that can be used to initialize the
  database with a single admin user and a long-lived JWT token for use
  in automation tests.
- Adds a JWT decoding/encoding CLI tool for debugging JWTs.

Note: Snapshots are not checked in, and must be generated manually. See
test/__snapshots__/.gitignore for more information.
2025-02-02 23:11:19 -08:00

318 lines
15 KiB
Vue

<script setup lang="ts">
import { useNuxtApp } from 'nuxt/app';
import useSimpleHead from '~/composables/useSimpleHead.ts';
import type { LocalDescriptionWithConfig } from '~/server/admin.ts';
import { longtimeCookieSetting } from '~/src/cookieSettings.ts';
import { newDate } from '~/src/helpers.ts';
import { useMainStore } from '~/store/index.ts';
const { $translator: translator, $isGranted: isGranted } = useNuxtApp();
useSimpleHead({
title: translator.translate('admin.header'),
}, translator);
const tokenCookie = useCookie('token', longtimeCookieSetting);
const impersonatorCookie = useCookie('impersonator', longtimeCookieSetting);
const statsAsyncData = useFetch('/api/admin/stats');
const allLocalesAsyncData = useFetch('/api/admin/all-locales');
await Promise.all([statsAsyncData, allLocalesAsyncData]);
const stats = statsAsyncData.data;
const allLocales = allLocalesAsyncData.data;
const store = useMainStore();
const adminNotifications = ref(store.user ? store.user.adminNotifications ?? 7 : 7);
watch(adminNotifications, async () => {
const res = await $fetch('/api/admin/set-notification-frequency', {
method: 'POST',
body: { frequency: adminNotifications.value },
});
await store.setToken(res.token);
});
const filterAttention = ref<boolean>(false);
const visibleLocales = computed((): Record<string, LocalDescriptionWithConfig> | null => {
if (allLocales.value === null) {
return null;
}
return Object.fromEntries(Object.entries(allLocales.value).filter(([locale, _]) => {
return isGranted('panel', locale) && statsAsyncData.data.value?.locales[locale];
}));
});
const router = useRouter();
const impersonate = async (email: string) => {
const { token } = await $fetch(`/api/admin/impersonate/${encodeURIComponent(email)}`);
impersonatorCookie.value = tokenCookie.value;
tokenCookie.value = token;
await router.push({ name: 'user' });
setTimeout(() => window.location.reload(), 500);
};
</script>
<template>
<Page wide>
<NotFound v-if="!$isGranted('panel')" />
<div v-else>
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center">
<div>
<h2>
<Icon v="user-cog" />
<T>admin.header</T>
</h2>
<p v-if="stats" class="small">
<em>
Stats calculated at:
<span
:class="stats.calculatedAt < (newDate().getTime() - 24 * 60 * 60 * 1000) / 1000
? `badge bg-danger text-white`
: ''"
>
{{ $datetime(stats.calculatedAt) }}
</span>
</em>
</p>
</div>
<div class="form-check form-switch my-2">
<label>
<input v-model="filterAttention" class="form-check-input" type="checkbox" role="switch">
<Icon v="filter" />
Only show items requiring attention
</label>
</div>
</div>
<h3>General</h3>
<div class="row mb-4">
<AdminDashboardCard
v-if="stats && ($isGranted('users') || $isGranted('community'))"
v-show="!filterAttention"
icon="users"
header="Users"
link="/admin/users"
:counts="[
{ count: stats.overall.users },
{ name: 'admins', count: stats.overall.admins },
]"
/>
<AdminDashboardCard
v-if="stats && ($isGranted('users') || $isGranted('community'))"
v-show="!filterAttention || stats.overall.bansPending"
icon="ban"
header="Pending bans"
link="/admin/pending-bans"
:counts="[
{ count: stats.overall.bansPending, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="stats && ($isGranted('users') || $isGranted('community'))"
v-show="!filterAttention || stats.overall.userReports"
icon="siren-on"
header="Abuse reports"
link="/admin/abuse-reports"
:counts="[
{ count: stats.overall.userReports, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-show="!filterAttention"
icon="user-cog"
link="/admin/moderation"
header="Moderation rules"
/>
<AdminDashboardCard
v-if="stats && $isGranted('code')"
v-show="!filterAttention || stats.overall.cardsQueue"
icon="id-card"
header="Cards queue"
:counts="[
{ count: stats.overall.cardsQueue, warning: 16, danger: 64 },
]"
/>
<AdminDashboardCard
v-if="stats && $isGranted('code')"
v-show="!filterAttention || stats.overall.linksQueue"
icon="link"
header="Links queue"
:counts="[
{ count: stats.overall.linksQueue, warning: 64, danger: 256 },
]"
/>
<AdminDashboardCard
v-show="!filterAttention"
icon="bell"
header="Email notifications"
>
<span v-for="(label, value) in { 0: 'Never', 1: 'Daily', 7: 'Weekly' }" class="form-check form-check-inline">
<input
:id="`notifiactionFrequency_${value}`"
v-model="adminNotifications"
class="form-check-input"
type="radio"
name="inlineRadioOptions"
:value="value"
>
<label class="form-check-label" :for="`notifiactionFrequency_${value}`">{{ label }}</label>
</span>
</AdminDashboardCard>
<AdminDashboardCard
v-show="!filterAttention"
v-if="$isGranted('users') || $isGranted('community')"
icon="user-secret"
header="@example"
>
<button class="btn btn-primary btn-sm" @click="impersonate('example@pronouns.page')">
Impersonate
</button>
</AdminDashboardCard>
<AdminDashboardCard
v-show="!filterAttention"
icon="b:discord"
link="https://team-discord.pronouns.page"
header="Team Discord"
/>
<AdminDashboardCard
v-show="!filterAttention"
icon="palette"
link="/design"
header="Design guidelines"
/>
<AdminDashboardCard
v-show="!filterAttention"
icon="file-spreadsheet"
link="/admin/timesheets"
header="Volunteering timesheets"
/>
</div>
<template v-for="({ name, extra, config, url, published }, locale) in visibleLocales" :key="locale">
<h3>
{{ name }} {{ extra ? `(${extra})` : '' }}
<small v-if="!published" class="text-muted">(not published yet)</small>
</h3>
<div class="row mb-4">
<AdminDashboardCard
v-if="stats"
v-show="!filterAttention"
:base-url="url"
icon="users"
header="Profiles"
link="/admin/profiles"
:counts="[
{ count: stats.locales[locale].users },
]"
/>
<AdminDashboardCard
v-if="stats && config.nouns && config.nouns.enabled && $isGranted('nouns', locale) && (stats.locales[locale].nouns.approved > 0 || stats.locales[locale].nouns.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].nouns.awaiting"
:base-url="url"
icon="book"
header="Dictionary"
:link="`/${config.nouns.route}`"
:counts="[
{ count: stats.locales[locale].nouns.approved },
{ name: 'awaiting', count: stats.locales[locale].nouns.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="stats && config.inclusive && config.inclusive.enabled && $isGranted('inclusive', locale) && (stats.locales[locale].inclusive.approved > 0 || stats.locales[locale].inclusive.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].inclusive.awaiting"
:base-url="url"
icon="book-heart"
header="Inclusive"
:link="`/${config.inclusive.route}`"
:counts="[
{ count: stats.locales[locale].inclusive.approved },
{ name: 'awaiting', count: stats.locales[locale].inclusive.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="stats && config.terminology && config.terminology.enabled && $isGranted('terms', locale) && (stats.locales[locale].terms.approved > 0 || stats.locales[locale].terms.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].terms.awaiting"
:base-url="url"
icon="flag"
header="Terminology"
:link="`/${config.terminology.route}`"
:counts="[
{ count: stats.locales[locale].terms.approved },
{ name: 'awaiting', count: stats.locales[locale].terms.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="stats && config.sources && config.sources.enabled && $isGranted('sources', locale) && (stats.locales[locale].sources.approved > 0 || stats.locales[locale].sources.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].sources.awaiting"
:base-url="url"
icon="books"
header="Sources"
:link="`/${config.sources.route}`"
:counts="[
{ count: stats.locales[locale].sources.approved },
{ name: 'awaiting', count: stats.locales[locale].sources.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="stats && config.names && config.names.enabled && $isGranted('names', locale) && (stats.locales[locale].names.approved > 0 || stats.locales[locale].names.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].names.awaiting"
:base-url="url"
icon="signature"
header="Names"
:link="`/${config.names.route}`"
:counts="[
{ count: stats.locales[locale].names.approved },
{ name: 'awaiting', count: stats.locales[locale].names.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="config.links.enabled && config.links.blog"
v-show="!filterAttention"
:base-url="url"
icon="pen-nib"
header="Blog"
>
<nuxt-link :to="{ name: 'admin-blog-editor' }" class="btn btn-sm btn-secondary text-white">Editor</nuxt-link>
</AdminDashboardCard>
<AdminDashboardCard
v-if="stats && $isGranted('translations', locale) && (stats.locales[locale].translations.missing > 0 || stats.locales[locale].translations.awaitingApproval > 0) || stats && $isGranted('code', locale) && stats.locales[locale].translations.awaitingMerge > 0"
v-show="!filterAttention || stats.locales[locale].translations.missing || stats.locales[locale].translations.awaitingApproval || stats?.locales[locale].translations.awaitingMerge"
:base-url="url"
icon="language"
header="Translations"
:counts="[
{
name: 'missing',
link: '/admin/translations/missing',
count: stats.locales[locale].translations.missing,
warning: 1,
danger: 16,
enabled: $isGranted('translations', locale),
},
{
name: 'proposed',
link: '/admin/translations/awaiting',
count: stats.locales[locale].translations.awaitingApproval,
warning: 1,
danger: 16,
enabled: $isGranted('translations', locale),
},
{
name: 'not merged',
link: '/admin/translations/awaiting',
count: stats.locales[locale].translations.awaitingMerge,
warning: 1,
danger: 16,
enabled: $isGranted('code', locale),
},
]"
/>
</div>
</template>
</div>
</Page>
</template>