mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-07 00:17:12 +08:00
feat: add redemption code system (#1133)
feat: add redemption code support
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import {
|
||||
WORKER_URL_ENV_OFF,
|
||||
WORKER_URL_SITE_PASSWORD,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const SITE_HEADERS = { 'x-custom-auth': 'e2e-site-pass' };
|
||||
const ADMIN_HEADERS = { ...SITE_HEADERS, 'x-admin-auth': 'e2e-admin-pass' };
|
||||
const futureExpiration = () => new Date(Date.now() + 3_600_000).toISOString();
|
||||
|
||||
const signTestToken = (payload: Record<string, unknown>, secret = 'e2e-site-password-secret') => {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
|
||||
return `${header}.${body}.${signature}`;
|
||||
};
|
||||
|
||||
const tokenPayload = (role: string) => ({
|
||||
user_id: 1,
|
||||
user_role: role,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
});
|
||||
|
||||
test.describe('Redemption feature access boundaries', () => {
|
||||
test('the disabled switch hides every user and Admin endpoint', async ({ request }) => {
|
||||
const settingsResponse = await request.get(`${WORKER_URL_ENV_OFF}/open_api/settings`);
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
expect((await settingsResponse.json()).enableRedeemCode).toBe(false);
|
||||
|
||||
const requests = [
|
||||
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/query`, { data: { code: 'anything' } }),
|
||||
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/result`, { data: { code: 'anything' } }),
|
||||
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/redeem`, {
|
||||
data: { code: 'anything', user_email: 'user@test.example.com' },
|
||||
}),
|
||||
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes?redeem_type=role`),
|
||||
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/export?redeem_type=role&limit=1`),
|
||||
request.post(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/batch`, {
|
||||
data: {
|
||||
count: 1,
|
||||
redeem_type: 'role',
|
||||
value: 'case-role',
|
||||
enabled: true,
|
||||
expires_at: futureExpiration(),
|
||||
},
|
||||
}),
|
||||
request.put(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`, {
|
||||
data: {
|
||||
redeem_type: 'role',
|
||||
value: 'case-role',
|
||||
enabled: true,
|
||||
expires_at: futureExpiration(),
|
||||
},
|
||||
}),
|
||||
request.delete(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`),
|
||||
];
|
||||
const responses = await Promise.all(requests);
|
||||
for (const response of responses) {
|
||||
expect(response.status()).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
test('site password takes priority over otherwise public redemption APIs', async ({ request }) => {
|
||||
const settingsResponse = await request.get(`${WORKER_URL_SITE_PASSWORD}/open_api/settings`);
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
expect(await settingsResponse.json()).toMatchObject({
|
||||
needAuth: true,
|
||||
enableRedeemCode: true,
|
||||
});
|
||||
|
||||
const blockedAdminResponse = await request.post(
|
||||
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||
{
|
||||
headers: { 'x-admin-auth': 'e2e-admin-pass' },
|
||||
data: {
|
||||
count: 1,
|
||||
redeem_type: 'role',
|
||||
value: 'case-role',
|
||||
enabled: true,
|
||||
expires_at: futureExpiration(),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(blockedAdminResponse.status()).toBe(401);
|
||||
|
||||
const createResponse = await request.post(
|
||||
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||
{
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
count: 1,
|
||||
redeem_type: 'role',
|
||||
value: 'case-role',
|
||||
enabled: true,
|
||||
expires_at: futureExpiration(),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
const code = (await createResponse.json()).codes[0] as string;
|
||||
|
||||
for (const path of ['query', 'result', 'redeem']) {
|
||||
const missingPassword = await request.post(
|
||||
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
|
||||
{ data: { code } },
|
||||
);
|
||||
expect(missingPassword.status()).toBe(401);
|
||||
const wrongPassword = await request.post(
|
||||
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
|
||||
{ headers: { 'x-custom-auth': 'wrong' }, data: { code } },
|
||||
);
|
||||
expect(wrongPassword.status()).toBe(401);
|
||||
}
|
||||
const validPassword = await request.post(`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, {
|
||||
headers: SITE_HEADERS,
|
||||
data: { code },
|
||||
});
|
||||
expect(validPassword.ok()).toBe(true);
|
||||
expect(await validPassword.json()).toEqual({
|
||||
redeem_type: 'role', value: 'case-role', status: 'unused',
|
||||
});
|
||||
|
||||
const listResponse = await request.get(
|
||||
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=role`
|
||||
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const row = (await listResponse.json()).results[0];
|
||||
const deleteResponse = await request.delete(
|
||||
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(deleteResponse.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('special-address redemption preserves the configured address regex', async ({ request }) => {
|
||||
const createResponse = await request.post(
|
||||
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||
{
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
count: 1,
|
||||
redeem_type: 'address_prefix_once',
|
||||
value: '',
|
||||
enabled: true,
|
||||
expires_at: futureExpiration(),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
const code = (await createResponse.json()).codes[0] as string;
|
||||
|
||||
const redeemResponse = await request.post(
|
||||
`${WORKER_URL_SITE_PASSWORD}/redeem_api/redeem`,
|
||||
{
|
||||
headers: SITE_HEADERS,
|
||||
data: { code, name: 'blocked', domain: 'test.example.com' },
|
||||
},
|
||||
);
|
||||
expect(redeemResponse.status()).toBe(400);
|
||||
|
||||
const queryResponse = await request.post(
|
||||
`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`,
|
||||
{ headers: SITE_HEADERS, data: { code } },
|
||||
);
|
||||
expect(queryResponse.ok()).toBe(true);
|
||||
|
||||
const listResponse = await request.get(
|
||||
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=address_prefix_once`
|
||||
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const row = (await listResponse.json()).results[0];
|
||||
const deleteResponse = await request.delete(
|
||||
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(deleteResponse.ok()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Redemption Admin authentication', () => {
|
||||
const baseUrl = `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes`;
|
||||
const listUrl = `${baseUrl}?redeem_type=address_prefix_once&limit=100&offset=0`;
|
||||
const codeData = () => ({
|
||||
count: 1,
|
||||
redeem_type: 'address_prefix_once',
|
||||
value: 'auth',
|
||||
enabled: true,
|
||||
expires_at: futureExpiration(),
|
||||
});
|
||||
const deniedCredentials: { name: string; headers: () => Record<string, string> }[] = [
|
||||
{ name: 'site password alone', headers: () => ({}) },
|
||||
{ name: 'wrong Admin password', headers: () => ({ 'x-admin-auth': 'wrong' }) },
|
||||
{
|
||||
name: 'mailbox JWT',
|
||||
headers: () => ({
|
||||
Authorization: `Bearer ${signTestToken({ address: 'mail@test.example.com', address_id: 1 })}`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'user account JWT',
|
||||
headers: () => ({
|
||||
'x-user-token': signTestToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 3600 }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'non-Admin role token',
|
||||
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('case-role')) }),
|
||||
},
|
||||
{
|
||||
name: 'forged Admin role token',
|
||||
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('admin'), 'wrong-secret') }),
|
||||
},
|
||||
{
|
||||
name: 'expired Admin role token',
|
||||
headers: () => ({
|
||||
'x-user-access-token': signTestToken({ ...tokenPayload('admin'), exp: Math.floor(Date.now() / 1000) - 60 }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Admin role token without expiration',
|
||||
headers: () => ({ 'x-user-access-token': signTestToken({ user_id: 1, user_role: 'admin' }) }),
|
||||
},
|
||||
{ name: 'malformed role token', headers: () => ({ 'x-user-access-token': 'not-a-jwt' }) },
|
||||
];
|
||||
|
||||
for (const credentials of deniedCredentials) {
|
||||
test(`rejects ${credentials.name} on all five endpoints without changing data`, async ({ request }) => {
|
||||
const original = codeData();
|
||||
const created = await request.post(`${baseUrl}/batch`, { headers: ADMIN_HEADERS, data: original });
|
||||
expect(created.ok()).toBe(true);
|
||||
const code = (await created.json()).codes[0] as string;
|
||||
const beforeResponse = await request.get(listUrl, { headers: ADMIN_HEADERS });
|
||||
expect(beforeResponse.ok()).toBe(true);
|
||||
const before = await beforeResponse.json();
|
||||
const row = before.results.find((item: { code: string }) => item.code === code);
|
||||
expect(row).toBeDefined();
|
||||
try {
|
||||
const headers = { ...SITE_HEADERS, ...credentials.headers() };
|
||||
const responses = await Promise.all([
|
||||
request.get(listUrl, { headers }),
|
||||
request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers }),
|
||||
request.post(`${baseUrl}/batch`, { headers, data: original }),
|
||||
request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'changed' } }),
|
||||
request.delete(`${baseUrl}/${row.id}`, { headers }),
|
||||
]);
|
||||
for (const response of responses) {
|
||||
expect(response.status(), response.url()).toBe(401);
|
||||
expect(await response.text()).not.toContain(code);
|
||||
}
|
||||
const after = await request.get(listUrl, { headers: ADMIN_HEADERS });
|
||||
expect(after.ok()).toBe(true);
|
||||
expect(await after.json()).toEqual(before);
|
||||
} finally {
|
||||
const deleted = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
|
||||
expect(deleted.ok()).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const authType of ['password', 'role token'] as const) {
|
||||
test(`accepts a valid Admin ${authType} for all five endpoints`, async ({ request }) => {
|
||||
const headers: Record<string, string> = authType === 'password'
|
||||
? ADMIN_HEADERS
|
||||
: { ...SITE_HEADERS, 'x-user-access-token': signTestToken(tokenPayload('admin')) };
|
||||
const original = codeData();
|
||||
const created = await request.post(`${baseUrl}/batch`, { headers, data: original });
|
||||
expect(created.ok()).toBe(true);
|
||||
const code = (await created.json()).codes[0] as string;
|
||||
const listResponse = await request.get(listUrl, { headers });
|
||||
expect(listResponse.ok()).toBe(true);
|
||||
const row = (await listResponse.json()).results.find((item: { code: string }) => item.code === code);
|
||||
expect(row).toBeDefined();
|
||||
let deleted = false;
|
||||
try {
|
||||
const exported = await request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers });
|
||||
expect(exported.ok()).toBe(true);
|
||||
expect(exported.headers()['content-type']).toContain('text/csv');
|
||||
expect(await exported.text()).toContain(code);
|
||||
|
||||
const updated = await request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'updated' } });
|
||||
expect(updated.ok()).toBe(true);
|
||||
const afterUpdate = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
|
||||
expect(afterUpdate.ok()).toBe(true);
|
||||
expect((await afterUpdate.json()).results).toEqual([
|
||||
expect.objectContaining({ id: row.id, code, value: 'updated' }),
|
||||
]);
|
||||
|
||||
const deletion = await request.delete(`${baseUrl}/${row.id}`, { headers });
|
||||
expect(deletion.ok()).toBe(true);
|
||||
deleted = true;
|
||||
const afterDelete = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
|
||||
expect(afterDelete.ok()).toBe(true);
|
||||
expect(await afterDelete.json()).toEqual({ results: [], count: 0 });
|
||||
} finally {
|
||||
if (!deleted) {
|
||||
const cleanup = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
|
||||
expect(cleanup.ok()).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import { deleteAddress, WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
const MAX_BATCH_SIZE = 500;
|
||||
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
|
||||
|
||||
async function createCodes(
|
||||
request: APIRequestContext,
|
||||
{
|
||||
count = 1,
|
||||
type = 'role',
|
||||
value = 'case-role',
|
||||
enabled = true,
|
||||
expiresAt = new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}: {
|
||||
count?: unknown;
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
enabled?: unknown;
|
||||
expiresAt?: unknown;
|
||||
},
|
||||
) {
|
||||
return await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
count,
|
||||
redeem_type: type,
|
||||
value,
|
||||
enabled,
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function listCodes(
|
||||
request: APIRequestContext,
|
||||
type: RedeemType,
|
||||
query = '',
|
||||
) {
|
||||
const response = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}&limit=100&offset=0`
|
||||
+ (query ? `&query=${encodeURIComponent(query)}` : ''),
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function deleteCode(request: APIRequestContext, id: number) {
|
||||
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Redemption code Admin API', () => {
|
||||
test('rejects malformed JSON when creating codes', async ({ request }) => {
|
||||
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||
headers: { ...ADMIN_HEADERS, 'content-type': 'application/json' },
|
||||
data: '{',
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('reports the enabled switch and exposes the migrated table', async ({ request }) => {
|
||||
const settingsResponse = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
expect((await settingsResponse.json()).enableRedeemCode).toBe(true);
|
||||
|
||||
const listResponse = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes?redeem_type=role&limit=20&offset=0`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(listResponse.ok()).toBe(true);
|
||||
await expect(listResponse.json()).resolves.toMatchObject({
|
||||
results: expect.any(Array),
|
||||
count: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
test('batch generates UUIDs, filters by type, updates, and deletes', async ({ request }) => {
|
||||
const createResponse = await createCodes(request, { count: 2 });
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
const created = await createResponse.json();
|
||||
expect(created).toMatchObject({ success: true, created: 2 });
|
||||
expect(created.codes).toHaveLength(2);
|
||||
expect(new Set(created.codes).size).toBe(2);
|
||||
expect(created.codes.every((code: string) => UUID_V4_PATTERN.test(code))).toBe(true);
|
||||
const [firstCode, secondCode] = created.codes as string[];
|
||||
const queryPrefix = firstCode.slice(0, 8);
|
||||
|
||||
const roleList = await listCodes(request, 'role', queryPrefix);
|
||||
expect(roleList.count).toBe(1);
|
||||
expect(roleList.results[0]).toMatchObject({
|
||||
code: firstCode,
|
||||
redeem_type: 'role',
|
||||
value: 'case-role',
|
||||
enabled: 1,
|
||||
redeemed: false,
|
||||
result: null,
|
||||
});
|
||||
const balanceList = await listCodes(request, 'send_balance', queryPrefix);
|
||||
expect(balanceList.count).toBe(0);
|
||||
|
||||
const firstId = roleList.results[0].id as number;
|
||||
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstId}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
redeem_type: 'role',
|
||||
value: 'empty-role',
|
||||
enabled: false,
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
expect(updateResponse.ok()).toBe(true);
|
||||
|
||||
const updatedList = await listCodes(request, 'role', firstCode);
|
||||
expect(updatedList.count).toBe(1);
|
||||
expect(updatedList.results[0]).toMatchObject({
|
||||
id: firstId,
|
||||
code: firstCode,
|
||||
value: 'empty-role',
|
||||
enabled: 0,
|
||||
});
|
||||
expect(updatedList.results[0].expires_at).toEqual(expect.any(String));
|
||||
|
||||
const secondList = await listCodes(request, 'role', secondCode);
|
||||
await deleteCode(request, firstId);
|
||||
await deleteCode(request, secondList.results[0].id);
|
||||
expect((await listCodes(request, 'role', firstCode)).count).toBe(0);
|
||||
});
|
||||
|
||||
test('normalizes typed values and accepts both empty and maximum usable prefixes', async ({ request }) => {
|
||||
const emptyResponse = await createCodes(request, {
|
||||
type: 'address_prefix_once',
|
||||
value: ' ',
|
||||
});
|
||||
expect(emptyResponse.ok()).toBe(true);
|
||||
const emptyCode = (await emptyResponse.json()).codes[0] as string;
|
||||
const maxResponse = await createCodes(request, {
|
||||
type: 'address_prefix_once',
|
||||
value: ` ${'A'.repeat(29)} `,
|
||||
});
|
||||
expect(maxResponse.ok()).toBe(true);
|
||||
const maxCode = (await maxResponse.json()).codes[0] as string;
|
||||
const balanceResponse = await createCodes(request, {
|
||||
type: 'send_balance',
|
||||
value: ' 42 ',
|
||||
});
|
||||
expect(balanceResponse.ok()).toBe(true);
|
||||
const balanceCode = (await balanceResponse.json()).codes[0] as string;
|
||||
|
||||
const emptyRow = (await listCodes(request, 'address_prefix_once', emptyCode)).results[0];
|
||||
const maxRow = (await listCodes(request, 'address_prefix_once', maxCode)).results[0];
|
||||
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
|
||||
expect(emptyRow.value).toBe('');
|
||||
expect(maxRow.value).toBe('a'.repeat(29));
|
||||
expect(balanceRow.value).toBe('42');
|
||||
await deleteCode(request, emptyRow.id);
|
||||
await deleteCode(request, maxRow.id);
|
||||
await deleteCode(request, balanceRow.id);
|
||||
});
|
||||
|
||||
test('rejects every invalid batch shape and typed business value', async ({ request }) => {
|
||||
const invalidRequests = [
|
||||
{ type: 'unknown', value: 'x' },
|
||||
{ type: 'role', value: '' },
|
||||
{ type: 'role', value: 'missing-role' },
|
||||
{ type: 'role', value: 'admin' },
|
||||
{ type: 'send_balance', value: '0' },
|
||||
{ type: 'send_balance', value: '-1' },
|
||||
{ type: 'send_balance', value: '1.5' },
|
||||
{ type: 'send_balance', value: '1000000001' },
|
||||
{ type: 'address_prefix_once', value: 'bad-' },
|
||||
{ type: 'address_prefix_once', value: 'a'.repeat(30) },
|
||||
{ count: 0 },
|
||||
{ count: -1 },
|
||||
{ count: 1.5 },
|
||||
{ count: MAX_BATCH_SIZE + 1 },
|
||||
{ count: '1' },
|
||||
{ count: null },
|
||||
{ enabled: 'yes' },
|
||||
{ value: 1 },
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: '' },
|
||||
{ expiresAt: 'not-a-date' },
|
||||
{ expiresAt: new Date(Date.now() - 60_000).toISOString() },
|
||||
];
|
||||
|
||||
for (const invalidRequest of invalidRequests) {
|
||||
const response = await createCodes(request, invalidRequest);
|
||||
expect(response.status(), JSON.stringify(invalidRequest)).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects invalid updates and IDs', async ({ request }) => {
|
||||
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||
const createResponse = await createCodes(request, { count: 2 });
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
const [firstCode, secondCode] = (await createResponse.json()).codes as string[];
|
||||
const firstRow = (await listCodes(request, 'role', firstCode)).results[0];
|
||||
const secondRow = (await listCodes(request, 'role', secondCode)).results[0];
|
||||
|
||||
const invalidId = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { redeem_type: 'role', value: 'case-role', enabled: true, expires_at: expiresAt },
|
||||
});
|
||||
expect(invalidId.status()).toBe(400);
|
||||
|
||||
const invalidRole = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { redeem_type: 'role', value: 'admin', enabled: true, expires_at: expiresAt },
|
||||
});
|
||||
expect(invalidRole.status()).toBe(400);
|
||||
|
||||
const wrongType = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
redeem_type: 'send_balance',
|
||||
value: '1',
|
||||
enabled: true,
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
expect(wrongType.status()).toBe(404);
|
||||
|
||||
const invalidDelete = await request.delete(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(invalidDelete.status()).toBe(400);
|
||||
|
||||
await deleteCode(request, firstRow.id);
|
||||
await deleteCode(request, secondRow.id);
|
||||
});
|
||||
|
||||
test('does not update a redeemed code', async ({ request }) => {
|
||||
let code = '';
|
||||
let addressResult: { jwt?: string } | undefined;
|
||||
try {
|
||||
const createResponse = await createCodes(request, {
|
||||
type: 'address_prefix_once',
|
||||
value: 'locked',
|
||||
});
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
code = (await createResponse.json()).codes[0] as string;
|
||||
|
||||
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: 'mail', domain: 'test.example.com' },
|
||||
});
|
||||
expect(redeemResponse.ok()).toBe(true);
|
||||
addressResult = await redeemResponse.json();
|
||||
|
||||
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||
expect(row.redeemed).toBe(true);
|
||||
expect(row.redeemed_at).toEqual(expect.any(String));
|
||||
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
redeem_type: 'address_prefix_once',
|
||||
value: 'changed',
|
||||
enabled: false,
|
||||
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
},
|
||||
});
|
||||
expect(updateResponse.status()).toBe(409);
|
||||
|
||||
const unchanged = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||
expect(unchanged).toMatchObject({
|
||||
code,
|
||||
value: 'locked',
|
||||
enabled: 1,
|
||||
redeemed: true,
|
||||
});
|
||||
await deleteCode(request, row.id);
|
||||
} finally {
|
||||
if (addressResult?.jwt) await deleteAddress(request, addressResult.jwt);
|
||||
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||
if (row) await deleteCode(request, row.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('exports one selected type with its business columns and enforces the limit', async ({ request }) => {
|
||||
const roleResponse = await createCodes(request, {});
|
||||
expect(roleResponse.ok()).toBe(true);
|
||||
const roleCode = (await roleResponse.json()).codes[0] as string;
|
||||
const balanceResponse = await createCodes(request, {
|
||||
type: 'send_balance',
|
||||
value: '25',
|
||||
});
|
||||
expect(balanceResponse.ok()).toBe(true);
|
||||
const balanceCode = (await balanceResponse.json()).codes[0] as string;
|
||||
|
||||
const roleExport = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=role&limit=100`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(roleExport.ok()).toBe(true);
|
||||
expect(roleExport.headers()['content-type']).toContain('text/csv');
|
||||
expect(roleExport.headers()['content-disposition']).toContain('redeem-codes-role.csv');
|
||||
const roleCsv = await roleExport.text();
|
||||
expect(roleCsv).toContain('role,redeemed_user_id');
|
||||
expect(roleCsv).toContain(roleCode);
|
||||
expect(roleCsv).not.toContain(balanceCode);
|
||||
|
||||
const balanceExport = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=send_balance&limit=100`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const balanceCsv = await balanceExport.text();
|
||||
expect(balanceCsv).toContain('amount,target_address');
|
||||
expect(balanceCsv).toContain(balanceCode);
|
||||
expect(balanceCsv).not.toContain(roleCode);
|
||||
|
||||
for (const query of [
|
||||
'redeem_type=unknown&limit=1',
|
||||
'redeem_type=role&limit=0',
|
||||
'redeem_type=role&limit=10001',
|
||||
'redeem_type=role&limit=1x',
|
||||
'redeem_type=role',
|
||||
]) {
|
||||
const response = await request.get(`${WORKER_URL}/admin/redeem_codes/export?${query}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(response.status(), query).toBe(400);
|
||||
}
|
||||
|
||||
const roleRow = (await listCodes(request, 'role', roleCode)).results[0];
|
||||
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
|
||||
await deleteCode(request, roleRow.id);
|
||||
await deleteCode(request, balanceRow.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,628 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
getAddressSender,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
|
||||
type AddressRedeemResult = {
|
||||
type: 'address_prefix_once';
|
||||
address: string;
|
||||
address_id: number;
|
||||
jwt: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
const uniqueValue = (label: string) => (
|
||||
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
);
|
||||
|
||||
async function createCode(
|
||||
request: APIRequestContext,
|
||||
type: RedeemType,
|
||||
value: string,
|
||||
options: { enabled?: boolean; expiresAt?: string } = {},
|
||||
) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
count: 1,
|
||||
redeem_type: type,
|
||||
value,
|
||||
enabled: options.enabled ?? true,
|
||||
expires_at: options.expiresAt ?? new Date(Date.now() + 3_600_000).toISOString(),
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
expect(body.codes).toHaveLength(1);
|
||||
return body.codes[0] as string;
|
||||
}
|
||||
|
||||
async function listCode(request: APIRequestContext, type: RedeemType, code: string) {
|
||||
const response = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
|
||||
+ `&limit=100&offset=0&query=${encodeURIComponent(code)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
return body.results.find((row: { code: string }) => row.code === code);
|
||||
}
|
||||
|
||||
async function deleteCode(request: APIRequestContext, type: RedeemType, code: string) {
|
||||
const row = await listCode(request, type, code);
|
||||
if (!row) return;
|
||||
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function queryCode(request: APIRequestContext, code: unknown) {
|
||||
return await request.post(`${WORKER_URL}/redeem_api/query`, { data: { code } });
|
||||
}
|
||||
|
||||
async function queryResult(request: APIRequestContext, code: unknown) {
|
||||
return await request.post(`${WORKER_URL}/redeem_api/result`, { data: { code } });
|
||||
}
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `${uniqueValue('redeem-user')}@test.example.com`;
|
||||
const createResponse = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { email, password: hashPassword('redeem-user-password') },
|
||||
});
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
const listResponse = await request.get(
|
||||
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const user = (await listResponse.json()).results.find(
|
||||
(row: { user_email: string }) => row.user_email === email,
|
||||
);
|
||||
expect(user).toBeTruthy();
|
||||
await setUserRole(request, user.id, null);
|
||||
return { id: user.id as number, email };
|
||||
}
|
||||
|
||||
async function setUserRole(request: APIRequestContext, userId: number, role: string | null) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { user_id: userId, role_text: role },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function deleteUser(request: APIRequestContext, userId: number) {
|
||||
await setUserRole(request, userId, null);
|
||||
const response = await request.delete(`${WORKER_URL}/admin/users/${userId}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Redemption code query and guards', () => {
|
||||
test('previews each supported type without consuming it', async ({ request }) => {
|
||||
const roleCode = await createCode(request, 'role', 'case-role');
|
||||
const balanceCode = await createCode(request, 'send_balance', '12');
|
||||
const addressCode = await createCode(request, 'address_prefix_once', 'vip');
|
||||
try {
|
||||
const roleResponse = await queryCode(request, roleCode);
|
||||
expect(roleResponse.ok()).toBe(true);
|
||||
expect(await roleResponse.json()).toEqual({
|
||||
redeem_type: 'role', value: 'case-role', status: 'unused',
|
||||
});
|
||||
|
||||
const balanceResponse = await queryCode(request, balanceCode);
|
||||
expect(balanceResponse.ok()).toBe(true);
|
||||
expect(await balanceResponse.json()).toEqual({
|
||||
redeem_type: 'send_balance', value: '12', status: 'unused',
|
||||
});
|
||||
|
||||
const addressResponse = await queryCode(request, addressCode);
|
||||
expect(addressResponse.ok()).toBe(true);
|
||||
expect(await addressResponse.json()).toEqual({
|
||||
redeem_type: 'address_prefix_once',
|
||||
value: 'vip',
|
||||
status: 'unused',
|
||||
});
|
||||
|
||||
expect((await listCode(request, 'role', roleCode)).redeemed_at).toBeNull();
|
||||
expect((await listCode(request, 'send_balance', balanceCode)).redeemed_at).toBeNull();
|
||||
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed_at).toBeNull();
|
||||
expect((await listCode(request, 'role', roleCode)).redeemed).toBe(false);
|
||||
expect((await listCode(request, 'send_balance', balanceCode)).redeemed).toBe(false);
|
||||
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed).toBe(false);
|
||||
expect((await queryResult(request, addressCode)).status()).toBe(400);
|
||||
} finally {
|
||||
await deleteCode(request, 'role', roleCode);
|
||||
await deleteCode(request, 'send_balance', balanceCode);
|
||||
await deleteCode(request, 'address_prefix_once', addressCode);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects missing, malformed, unknown, and disabled codes, but reports expired codes', async ({ request }) => {
|
||||
const disabledCode = await createCode(request, 'role', 'case-role', { enabled: false });
|
||||
const expiredCode = await createCode(request, 'role', 'case-role', {
|
||||
expiresAt: new Date(Date.now() + 5_000).toISOString(),
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_500));
|
||||
for (const code of [undefined, null, '', 'x'.repeat(257), uniqueValue('missing')]) {
|
||||
expect((await queryCode(request, code)).status()).toBe(400);
|
||||
}
|
||||
expect((await queryCode(request, disabledCode)).status()).toBe(400);
|
||||
const expiredResponse = await queryCode(request, expiredCode);
|
||||
expect(expiredResponse.ok()).toBe(true);
|
||||
expect(await expiredResponse.json()).toEqual({
|
||||
redeem_type: 'role', value: 'case-role', status: 'expired',
|
||||
});
|
||||
} finally {
|
||||
await deleteCode(request, 'role', disabledCode);
|
||||
await deleteCode(request, 'role', expiredCode);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects malformed JSON request bodies', async ({ request }) => {
|
||||
const options = {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: '{',
|
||||
};
|
||||
for (const path of ['/query', '/result', '/redeem']) {
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api${path}`, options);
|
||||
expect(response.status()).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses the code type and does not consume when its required target is missing', async ({ request }) => {
|
||||
const code = await createCode(request, 'send_balance', '9');
|
||||
try {
|
||||
const mismatchResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: 'nobody@test.example.com' },
|
||||
});
|
||||
expect(mismatchResponse.status()).toBe(400);
|
||||
expect((await queryCode(request, code)).ok()).toBe(true);
|
||||
expect((await listCode(request, 'send_balance', code)).redeemed_at).toBeNull();
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Role redemption', () => {
|
||||
test('concurrent different roles consume only the winning code', async ({ request }) => {
|
||||
const user = await createUser(request);
|
||||
const roles = ['case-role', 'empty-role'];
|
||||
const codes = await Promise.all(roles.map((role) => createCode(request, 'role', role)));
|
||||
try {
|
||||
const responses = await Promise.all(codes.map((code) => (
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email },
|
||||
})
|
||||
)));
|
||||
expect(responses.filter((response) => response.ok())).toHaveLength(1);
|
||||
expect(responses.filter((response) => response.status() === 409)).toHaveLength(1);
|
||||
const winner = responses.findIndex((response) => response.ok());
|
||||
const loser = 1 - winner;
|
||||
const rows = await Promise.all(codes.map((code) => listCode(request, 'role', code)));
|
||||
expect(rows[winner].redeemed).toBe(true);
|
||||
expect(rows[loser]).toMatchObject({ redeemed: false, result: null, redeemed_at: null });
|
||||
const users = await request.get(
|
||||
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect((await users.json()).results[0].role_text).toBe(roles[winner]);
|
||||
} finally {
|
||||
for (const code of codes) await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies a configured role by case-insensitive account email and consumes once', async ({ request }) => {
|
||||
const user = await createUser(request);
|
||||
const code = await createCode(request, 'role', 'case-role');
|
||||
try {
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email.toUpperCase() },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.json()).toEqual({
|
||||
success: true,
|
||||
type: 'role',
|
||||
role: 'case-role',
|
||||
user_email: user.email,
|
||||
});
|
||||
|
||||
const userResponse = await request.get(
|
||||
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect((await userResponse.json()).results[0].role_text).toBe('case-role');
|
||||
|
||||
const row = await listCode(request, 'role', code);
|
||||
expect(row.redeemed).toBe(true);
|
||||
expect(row.redeemed_at).toEqual(expect.any(String));
|
||||
expect(JSON.parse(row.result)).toMatchObject({
|
||||
type: 'role',
|
||||
user_id: user.id,
|
||||
user_email: user.email,
|
||||
role: 'case-role',
|
||||
});
|
||||
const publicResult = await queryResult(request, code);
|
||||
expect(publicResult.ok()).toBe(true);
|
||||
expect(await publicResult.json()).toEqual({
|
||||
type: 'role', role: 'case-role', user_email: user.email,
|
||||
});
|
||||
|
||||
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email },
|
||||
});
|
||||
expect(repeatedResponse.status()).toBe(400);
|
||||
expect(await (await queryCode(request, code)).json()).toEqual({
|
||||
redeem_type: 'role', value: 'case-role', status: 'redeemed',
|
||||
});
|
||||
} finally {
|
||||
await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts the same existing role', async ({ request }) => {
|
||||
const user = await createUser(request);
|
||||
const code = await createCode(request, 'role', 'case-role');
|
||||
try {
|
||||
await setUserRole(request, user.id, 'case-role');
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
} finally {
|
||||
await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the code unused when the account is missing or has a conflicting role', async ({ request }) => {
|
||||
const missingCode = await createCode(request, 'role', 'case-role');
|
||||
const conflictCode = await createCode(request, 'role', 'case-role');
|
||||
const user = await createUser(request);
|
||||
try {
|
||||
const missingResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: missingCode, user_email: `${uniqueValue('missing')}@test.example.com` },
|
||||
});
|
||||
expect(missingResponse.status()).toBe(400);
|
||||
expect((await queryCode(request, missingCode)).ok()).toBe(true);
|
||||
|
||||
await setUserRole(request, user.id, 'empty-role');
|
||||
const conflictResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: conflictCode, user_email: user.email },
|
||||
});
|
||||
expect(conflictResponse.status()).toBe(409);
|
||||
expect((await queryCode(request, conflictCode)).ok()).toBe(true);
|
||||
expect((await listCode(request, 'role', conflictCode)).result).toBeNull();
|
||||
} finally {
|
||||
await deleteCode(request, 'role', missingCode);
|
||||
await deleteCode(request, 'role', conflictCode);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Sending-credit redemption', () => {
|
||||
test('preserves default credits before the mailbox is first opened', async ({ request }) => {
|
||||
const address = await createTestAddress(request, 'rcfresh');
|
||||
const code = await createCode(request, 'send_balance', '7');
|
||||
try {
|
||||
const before = await request.get(
|
||||
`${WORKER_URL}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address.address)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect((await before.json()).results).toHaveLength(0);
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.json()).toMatchObject({ amount: 7, balance: 17 });
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const settings = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||
});
|
||||
expect(settings.ok()).toBe(true);
|
||||
}
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(17);
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
await deleteAddress(request, address.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('coalesces NULL balance, preserves disabled state, records result, and consumes once', async ({ request }) => {
|
||||
const address = await createTestAddress(request, 'rcnull');
|
||||
const code = await createCode(request, 'send_balance', '7');
|
||||
try {
|
||||
const settingsResponse = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||
});
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
const sender = await getAddressSender(request, address.address);
|
||||
const nullResponse = await request.post(`${WORKER_URL}/admin/address_sender`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
address: address.address,
|
||||
address_id: sender.id,
|
||||
balance: null,
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
expect(nullResponse.ok()).toBe(true);
|
||||
|
||||
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address.toUpperCase() },
|
||||
});
|
||||
expect(redeemResponse.ok()).toBe(true);
|
||||
expect(await redeemResponse.json()).toEqual({
|
||||
success: true,
|
||||
type: 'send_balance',
|
||||
address: address.address,
|
||||
amount: 7,
|
||||
balance: 7,
|
||||
});
|
||||
|
||||
const updatedSender = await getAddressSender(request, address.address);
|
||||
expect(updatedSender.balance).toBe(7);
|
||||
expect(updatedSender.enabled).toBe(0);
|
||||
expect(JSON.parse((await listCode(request, 'send_balance', code)).result)).toMatchObject({
|
||||
type: 'send_balance',
|
||||
address: address.address,
|
||||
amount: 7,
|
||||
});
|
||||
const publicResult = await queryResult(request, code);
|
||||
expect(publicResult.ok()).toBe(true);
|
||||
expect(await publicResult.json()).toEqual({
|
||||
type: 'send_balance', address: address.address, amount: 7,
|
||||
});
|
||||
|
||||
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address },
|
||||
});
|
||||
expect(repeatedResponse.status()).toBe(400);
|
||||
expect(await (await queryCode(request, code)).json()).toEqual({
|
||||
redeem_type: 'send_balance', value: '7', status: 'redeemed',
|
||||
});
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
await deleteAddress(request, address.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not consume when the target address is missing', async ({ request }) => {
|
||||
const code = await createCode(request, 'send_balance', '5');
|
||||
try {
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: `${uniqueValue('missing')}@test.example.com` },
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
expect((await queryCode(request, code)).ok()).toBe(true);
|
||||
expect((await listCode(request, 'send_balance', code)).result).toBeNull();
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('concurrent requests add the balance exactly once', async ({ request }) => {
|
||||
const address = await createTestAddress(request, 'rcrace');
|
||||
const code = await createCode(request, 'send_balance', '5');
|
||||
try {
|
||||
await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||
});
|
||||
const before = await getAddressSender(request, address.address);
|
||||
const responses = await Promise.all([
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address },
|
||||
}),
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address },
|
||||
}),
|
||||
]);
|
||||
expect(responses.filter((response) => response.ok())).toHaveLength(1);
|
||||
expect(responses.filter((response) => [400, 409].includes(response.status()))).toHaveLength(1);
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(before.balance + 5);
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
await deleteAddress(request, address.jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Special-address redemption', () => {
|
||||
test('uses the redemption prefix without the system prefix and repeats the complete original result', async ({ request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'vip');
|
||||
const name = `a${Math.random().toString(36).slice(2, 8)}`;
|
||||
let result: AddressRedeemResult | undefined;
|
||||
try {
|
||||
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name, domain: 'TEST.EXAMPLE.COM' },
|
||||
});
|
||||
expect(firstResponse.ok()).toBe(true);
|
||||
result = await firstResponse.json();
|
||||
expect(result).toMatchObject({
|
||||
type: 'address_prefix_once',
|
||||
address: `vip${name}@test.example.com`,
|
||||
address_id: expect.any(Number),
|
||||
jwt: expect.any(String),
|
||||
password: expect.any(String),
|
||||
});
|
||||
expect(result.address).not.toContain('tmpvip');
|
||||
|
||||
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: 'ignored', domain: 'manual.example.com' },
|
||||
});
|
||||
expect(repeatedResponse.ok()).toBe(true);
|
||||
expect(await repeatedResponse.json()).toEqual(result);
|
||||
|
||||
const previewResponse = await queryCode(request, code);
|
||||
expect(previewResponse.ok()).toBe(true);
|
||||
const preview = await previewResponse.json();
|
||||
expect(preview).toEqual({
|
||||
redeem_type: 'address_prefix_once', value: 'vip', status: 'redeemed',
|
||||
});
|
||||
const publicResult = await queryResult(request, code);
|
||||
expect(publicResult.ok()).toBe(true);
|
||||
expect(await publicResult.json()).toEqual({
|
||||
type: 'address_prefix_once',
|
||||
address: result.address,
|
||||
jwt: result.jwt,
|
||||
password: result.password,
|
||||
});
|
||||
const row = await listCode(request, 'address_prefix_once', code);
|
||||
expect(JSON.parse(row.result)).toEqual(result);
|
||||
} finally {
|
||||
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('supports an empty prefix and the longest usable prefix', async ({ request }) => {
|
||||
const emptyCode = await createCode(request, 'address_prefix_once', '');
|
||||
const maxCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
|
||||
const emptyName = `n${Math.random().toString(36).slice(2, 8)}`;
|
||||
let emptyResult: AddressRedeemResult | undefined;
|
||||
let maxResult: AddressRedeemResult | undefined;
|
||||
try {
|
||||
const emptyResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: emptyCode, name: emptyName, domain: 'test.example.com' },
|
||||
});
|
||||
expect(emptyResponse.ok()).toBe(true);
|
||||
emptyResult = await emptyResponse.json();
|
||||
expect(emptyResult.address).toBe(`${emptyName}@test.example.com`);
|
||||
|
||||
const maxResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: maxCode, name: 'z', domain: 'test.example.com' },
|
||||
});
|
||||
expect(maxResponse.ok()).toBe(true);
|
||||
maxResult = await maxResponse.json();
|
||||
expect(maxResult.address.split('@')[0]).toBe(`${'a'.repeat(29)}z`);
|
||||
} finally {
|
||||
if (emptyResult?.jwt) await deleteAddress(request, emptyResult.jwt);
|
||||
if (maxResult?.jwt) await deleteAddress(request, maxResult.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', emptyCode);
|
||||
await deleteCode(request, 'address_prefix_once', maxCode);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps length and domain validation without consuming failed codes', async ({ request }) => {
|
||||
const lengthCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
|
||||
const domainCode = await createCode(request, 'address_prefix_once', 'vip');
|
||||
try {
|
||||
const lengthResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: lengthCode, name: 'zz', domain: 'test.example.com' },
|
||||
});
|
||||
expect(lengthResponse.status()).toBe(400);
|
||||
|
||||
const domainResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: domainCode, name: 'valid', domain: 'invalid.example.com' },
|
||||
});
|
||||
expect(domainResponse.status()).toBe(400);
|
||||
|
||||
expect((await queryCode(request, lengthCode)).ok()).toBe(true);
|
||||
expect((await queryCode(request, domainCode)).ok()).toBe(true);
|
||||
} finally {
|
||||
await deleteCode(request, 'address_prefix_once', lengthCode);
|
||||
await deleteCode(request, 'address_prefix_once', domainCode);
|
||||
}
|
||||
});
|
||||
|
||||
test('generates a valid random suffix when the user omits the name', async ({ request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'rnd');
|
||||
let result: AddressRedeemResult | undefined;
|
||||
try {
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, domain: 'test.example.com' },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
result = await response.json();
|
||||
const localPart = result.address.split('@')[0];
|
||||
expect(localPart.startsWith('rnd')).toBe(true);
|
||||
expect(localPart.length).toBeLessThanOrEqual(30);
|
||||
expect(localPart.length).toBeGreaterThan(3);
|
||||
} finally {
|
||||
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not return an address result after its original address is removed', async ({ request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'removed');
|
||||
try {
|
||||
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: 'mail', domain: 'test.example.com' },
|
||||
});
|
||||
const first = await firstResponse.json();
|
||||
await deleteAddress(request, first.jwt);
|
||||
|
||||
const repeatResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: 'ignored', domain: 'test.example.com' },
|
||||
});
|
||||
expect(repeatResponse.status()).toBe(400);
|
||||
const resultResponse = await request.post(`${WORKER_URL}/redeem_api/result`, {
|
||||
data: { code },
|
||||
});
|
||||
expect(resultResponse.status()).toBe(400);
|
||||
} finally {
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('concurrent creations converge on one complete result', async ({ request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'race');
|
||||
let result: AddressRedeemResult | undefined;
|
||||
try {
|
||||
const responses = await Promise.all([
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: `a${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
|
||||
}),
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: `b${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
|
||||
}),
|
||||
]);
|
||||
expect(responses.every((response) => response.ok())).toBe(true);
|
||||
const results = await Promise.all(responses.map((response) => response.json()));
|
||||
expect(results[1]).toEqual(results[0]);
|
||||
result = results[0];
|
||||
expect(result.password).toEqual(expect.any(String));
|
||||
expect(JSON.parse((await listCode(request, 'address_prefix_once', code)).result)).toEqual(result);
|
||||
} finally {
|
||||
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('expiry stops retrieval of an existing result', async ({ request }) => {
|
||||
const expiredCode = await createCode(request, 'address_prefix_once', 'exp', {
|
||||
expiresAt: new Date(Date.now() + 5_000).toISOString(),
|
||||
});
|
||||
let expiredResult: AddressRedeemResult | undefined;
|
||||
try {
|
||||
expiredResult = await (await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: expiredCode, name: 'mail', domain: 'test.example.com' },
|
||||
})).json();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_500));
|
||||
|
||||
expect((await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: expiredCode },
|
||||
})).status()).toBe(400);
|
||||
expect(await (await queryCode(request, expiredCode)).json()).toMatchObject({ status: 'expired' });
|
||||
expect((await queryResult(request, expiredCode)).status()).toBe(400);
|
||||
} finally {
|
||||
if (expiredResult?.jwt) await deleteAddress(request, expiredResult.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', expiredCode);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
FRONTEND_URL,
|
||||
FRONTEND_URL_ENV_OFF,
|
||||
WORKER_URL,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
const uniqueValue = (label: string) => (
|
||||
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
);
|
||||
|
||||
async function createCode(
|
||||
request: APIRequestContext,
|
||||
type: 'role' | 'send_balance' | 'address_prefix_once',
|
||||
value: string,
|
||||
) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
count: 1,
|
||||
redeem_type: type,
|
||||
value,
|
||||
enabled: true,
|
||||
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
expect(body.codes).toHaveLength(1);
|
||||
return body.codes[0] as string;
|
||||
}
|
||||
|
||||
async function findCode(request: APIRequestContext, type: string, code: string) {
|
||||
const response = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
|
||||
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const body = await response.json();
|
||||
return body.results.find((row: { code: string }) => row.code === code);
|
||||
}
|
||||
|
||||
async function deleteCode(request: APIRequestContext, type: string, code: string) {
|
||||
const row = await findCode(request, type, code);
|
||||
if (!row) return;
|
||||
await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `${uniqueValue('browser-redeem-user')}@test.example.com`;
|
||||
const password = createHash('sha256').update('browser-redeem-password').digest('hex');
|
||||
const response = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { email, password },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const usersResponse = await request.get(
|
||||
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const user = (await usersResponse.json()).results[0];
|
||||
await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { user_id: user.id, role_text: null },
|
||||
});
|
||||
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginResponse.ok()).toBe(true);
|
||||
return { id: user.id as number, email, jwt: (await loginResponse.json()).jwt as string };
|
||||
}
|
||||
|
||||
async function deleteUser(request: APIRequestContext, userId: number) {
|
||||
await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { user_id: userId, role_text: null },
|
||||
});
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`, { headers: ADMIN_HEADERS });
|
||||
}
|
||||
|
||||
test('the mailbox entry opens the pure redemption page and completes role redemption', async ({ page, request }) => {
|
||||
const user = await createUser(request);
|
||||
const code = await createCode(request, 'role', 'case-role');
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await expect(page.getByTestId('redeem-entry')).toBeVisible();
|
||||
await page.getByTestId('redeem-entry').click();
|
||||
await expect(page).toHaveURL(/\/en\/redeem$/);
|
||||
await expect(page.getByTestId('redeem-code-link')).toHaveAttribute(
|
||||
'href', 'https://example.com/redeem-codes',
|
||||
);
|
||||
await page.evaluate((jwt) => localStorage.setItem('userJwt', jwt), user.jwt);
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname === '/user_api/settings'
|
||||
));
|
||||
await page.reload();
|
||||
await settingsResponse;
|
||||
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Role benefits' })).toBeVisible();
|
||||
await expect(page.getByTestId('redeem-user-email')).toHaveCount(0);
|
||||
await page.getByTestId('redeem-now').click();
|
||||
const userEmailInput = page.getByTestId('redeem-user-email').locator('input');
|
||||
await expect(userEmailInput).toHaveValue(user.email);
|
||||
await userEmailInput.fill(user.email.toUpperCase());
|
||||
const refreshedSettings = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname === '/user_api/settings' && response.ok()
|
||||
));
|
||||
await page.getByRole('button', { name: 'Confirm redemption' }).click();
|
||||
const refreshedUser = await (await refreshedSettings).json();
|
||||
expect(refreshedUser.user_role.role).toBe('case-role');
|
||||
expect(refreshedUser.access_token).toEqual(expect.any(String));
|
||||
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
|
||||
await expect(page.getByText(user.email, { exact: false })).toBeVisible();
|
||||
await expect(page.getByText('case-role', { exact: false })).toBeVisible();
|
||||
|
||||
const row = await findCode(request, 'role', code);
|
||||
expect(JSON.parse(row.result)).toMatchObject({ user_email: user.email, role: 'case-role' });
|
||||
await page.getByRole('button', { name: 'Redeem another code' }).click();
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
const nextQuery = page.waitForRequest((request) => (
|
||||
new URL(request.url()).pathname === '/redeem_api/query'
|
||||
));
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
expect((await nextQuery).headers()['x-user-access-token']).toBe(refreshedUser.access_token);
|
||||
} finally {
|
||||
await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('sending credits defaults to the current mailbox and keeps the target editable', async ({ page, request }) => {
|
||||
const address = await createTestAddress(request, 'rdb-');
|
||||
const code = await createCode(request, 'send_balance', '3');
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await expect(page.getByTestId('redeem-entry')).toBeVisible();
|
||||
await page.getByTestId('redeem-entry').click();
|
||||
await expect(page).toHaveURL(/\/en\/redeem$/);
|
||||
await page.evaluate((jwt) => localStorage.setItem('jwt', jwt), address.jwt);
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname === '/api/settings'
|
||||
));
|
||||
await page.reload();
|
||||
await settingsResponse;
|
||||
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
await page.getByTestId('redeem-now').click();
|
||||
const targetInput = page.getByTestId('redeem-target-address').locator('input');
|
||||
await expect(targetInput).toHaveValue(address.address);
|
||||
await targetInput.fill(address.address.toUpperCase());
|
||||
await page.getByRole('button', { name: 'Confirm redemption' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
await deleteAddress(request, address.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('the special-address page shows and retrieves the same full credentials', async ({ page, request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'ui');
|
||||
const name = `m${Math.random().toString(36).slice(2, 8)}`;
|
||||
let jwt = '';
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/redeem`);
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Custom mailbox' })).toBeVisible();
|
||||
await expect(page.getByText('Unused', { exact: true })).toBeVisible();
|
||||
await page.getByTestId('redeem-address-name').locator('input').fill(name);
|
||||
await page.getByRole('button', { name: 'Redeem mailbox' }).click();
|
||||
|
||||
const addressValue = page.getByTestId('address-credential-address');
|
||||
const passwordValue = page.getByTestId('address-credential-password');
|
||||
const jwtValue = page.getByTestId('address-credential-jwt');
|
||||
await expect(addressValue).toHaveText(`ui${name}@test.example.com`);
|
||||
const firstAddress = await addressValue.innerText();
|
||||
const firstPassword = await passwordValue.innerText();
|
||||
const firstJwt = await jwtValue.innerText();
|
||||
expect(firstPassword).not.toBe('');
|
||||
expect(firstJwt).not.toBe('');
|
||||
jwt = firstJwt;
|
||||
|
||||
await page.getByRole('button', { name: 'Redeem another code' }).click();
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
await expect(page.getByText('Used', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(firstAddress, { exact: false })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'View redemption result' }).click();
|
||||
await expect(page.getByTestId('address-credential-address')).toHaveText(firstAddress);
|
||||
await expect(page.getByTestId('address-credential-password')).toHaveText(firstPassword);
|
||||
await expect(page.getByTestId('address-credential-jwt')).toHaveText(firstJwt);
|
||||
} finally {
|
||||
if (jwt) {
|
||||
await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
}
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin can batch-create, search, export, and display a redemption result', async ({ page, request }) => {
|
||||
let code = '';
|
||||
const user = await createUser(request);
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
await expect(page.getByTestId('redeem-admin-create')).toBeVisible();
|
||||
await page.getByTestId('redeem-admin-create').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByTestId('redeem-admin-count').locator('input').fill('1');
|
||||
await dialog.getByTestId('redeem-admin-role').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: 'case-role' }).click();
|
||||
await dialog.getByRole('button', { name: 'Generate' }).click();
|
||||
await expect(page.getByText('Select a future expiration time')).toBeVisible();
|
||||
const expirationInput = dialog.locator('.n-date-picker input');
|
||||
await expirationInput.fill('2099-01-01 00:00:00');
|
||||
await expirationInput.press('Enter');
|
||||
const createResponsePromise = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname === '/admin/redeem_codes/batch'
|
||||
));
|
||||
const createdCodesDownload = page.waitForEvent('download');
|
||||
await dialog.getByRole('button', { name: 'Generate' }).click();
|
||||
const createResponse = await createResponsePromise;
|
||||
code = (await createResponse.json()).codes[0];
|
||||
expect((await createdCodesDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
|
||||
await expect(page.getByText('Redemption codes generated: 1.')).toBeVisible();
|
||||
|
||||
await page.getByTestId('redeem-admin-search').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
await expect(page.getByText(code, { exact: true })).toBeVisible();
|
||||
|
||||
const exportDownload = page.waitForEvent('download');
|
||||
await page.getByTestId('redeem-admin-export').click();
|
||||
await expect(page.getByText('Export rows (maximum 10000)')).toBeVisible();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Download CSV' }).click();
|
||||
expect((await exportDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
|
||||
|
||||
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email },
|
||||
});
|
||||
expect(redeemResponse.ok()).toBe(true);
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
await expect(page.getByText(user.email, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
if (code) await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin ignores a stale list response after switching redemption type', async ({ page }) => {
|
||||
let releaseRole!: () => void;
|
||||
let markRoleRequested!: () => void;
|
||||
let markRoleCompleted!: () => void;
|
||||
const roleGate = new Promise<void>((resolve) => { releaseRole = resolve; });
|
||||
const roleRequested = new Promise<void>((resolve) => { markRoleRequested = resolve; });
|
||||
const roleCompleted = new Promise<void>((resolve) => { markRoleCompleted = resolve; });
|
||||
|
||||
await page.route('**/admin/redeem_codes?*', async (route) => {
|
||||
const type = new URL(route.request().url()).searchParams.get('redeem_type');
|
||||
if (type === 'role') {
|
||||
markRoleRequested();
|
||||
await roleGate;
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
count: 1,
|
||||
results: [{
|
||||
id: 1,
|
||||
code: 'STALE-ROLE-CODE',
|
||||
redeem_type: 'role',
|
||||
value: 'case-role',
|
||||
result: null,
|
||||
enabled: 1,
|
||||
expires_at: '2099-01-01 00:00:00',
|
||||
redeemed_at: null,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
markRoleCompleted();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
count: 1,
|
||||
results: [{
|
||||
id: 2,
|
||||
code: 'CURRENT-BALANCE-CODE',
|
||||
redeem_type: 'send_balance',
|
||||
value: '20',
|
||||
result: null,
|
||||
enabled: 1,
|
||||
expires_at: '2099-01-01 00:00:00',
|
||||
redeemed_at: null,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
await roleRequested;
|
||||
|
||||
await page.getByTestId('redeem-admin-filter-type').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: 'Sending credits' }).click();
|
||||
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
|
||||
|
||||
releaseRole();
|
||||
await roleCompleted;
|
||||
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('STALE-ROLE-CODE', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const { locale, roleRequired, roleType, addressType, create, generate, prefixError } of [
|
||||
{
|
||||
locale: 'en', roleRequired: 'Select a role', roleType: 'Role benefits',
|
||||
addressType: 'Custom mailbox', create: 'Batch Generate', generate: 'Generate',
|
||||
prefixError: 'Use only letters and digits, up to 29 characters. Leave empty for no prefix.',
|
||||
},
|
||||
{
|
||||
locale: 'zh', roleRequired: '请选择角色', roleType: '角色权益',
|
||||
addressType: '专属邮箱', create: '批量生成', generate: '生成',
|
||||
prefixError: '前缀仅支持英文字母和数字,最多 29 个字符;留空表示无前缀。',
|
||||
},
|
||||
]) {
|
||||
test(`Admin validates missing role and invalid prefix in ${locale}`, async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||
});
|
||||
let createRequests = 0;
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'POST'
|
||||
&& new URL(request.url()).pathname === '/admin/redeem_codes/batch') {
|
||||
createRequests++;
|
||||
}
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
|
||||
await expect(page.getByTestId('redeem-admin-filter-type')).toContainText(roleType);
|
||||
await page.getByRole('button', { name: create, exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: generate, exact: true }).click();
|
||||
await expect(page.getByText(roleRequired, { exact: true })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'close', exact: true }).click();
|
||||
await page.getByTestId('redeem-admin-filter-type').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: addressType }).click();
|
||||
await page.getByRole('button', { name: create, exact: true }).click();
|
||||
await dialog.locator('input[maxlength]').fill('bad-');
|
||||
await dialog.getByRole('button', { name: generate, exact: true }).click();
|
||||
await expect(page.getByText(prefixError, { exact: true })).toBeVisible();
|
||||
expect(createRequests).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('the disabled frontend hides the entry and redirects the page', async ({ page }) => {
|
||||
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/`);
|
||||
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
|
||||
|
||||
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/redeem`);
|
||||
await expect(page).not.toHaveURL(/\/redeem$/);
|
||||
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/admin`);
|
||||
await expect(page.getByTestId('redeem-admin-create')).toHaveCount(0);
|
||||
await expect(page.getByText('Redemption Codes', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { after, before, test } from 'node:test';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
let server;
|
||||
let onMailpitMessage;
|
||||
|
||||
before(async () => {
|
||||
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||
await once(server, 'listening');
|
||||
const originalApi = process.env.MAILPIT_API;
|
||||
process.env.MAILPIT_API = `http://127.0.0.1:${server.address().port}/api`;
|
||||
try {
|
||||
({ onMailpitMessage } = await import('../../fixtures/test-helpers.ts'));
|
||||
} finally {
|
||||
if (originalApi === undefined) delete process.env.MAILPIT_API;
|
||||
else process.env.MAILPIT_API = originalApi;
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
for (const client of server.clients) client.terminate();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
const target = { Subject: 'target-message' };
|
||||
const newEvent = JSON.stringify({ Type: 'new', Data: target });
|
||||
const statsEvent = JSON.stringify({ Type: 'stats', Data: { total: 1 } });
|
||||
|
||||
for (const [name, payload] of [
|
||||
['single event', newEvent],
|
||||
['new event followed by stats', `${newEvent}\n${statsEvent}`],
|
||||
['stats followed by new event', `${statsEvent}\n${newEvent}`],
|
||||
['malformed and unrelated events before the match',
|
||||
`invalid-json\n${JSON.stringify({ Type: 'new', Data: { Subject: 'other' } })}\n${newEvent}\n`],
|
||||
]) {
|
||||
test(name, async () => {
|
||||
const connected = once(server, 'connection');
|
||||
const listener = onMailpitMessage((mail) => mail.Subject === target.Subject, { timeout: 1000 });
|
||||
const received = assert.doesNotReject(async () => {
|
||||
assert.deepEqual(await listener.message, target);
|
||||
});
|
||||
const [client] = await connected;
|
||||
await listener.ready;
|
||||
client.send(payload);
|
||||
await received;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user