@owlmeans/web-auth-email 0.0.0-stage → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -2
- package/THIRD-PARTY-NOTICES.txt +24 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/web-auth-email/SKILL.md +119 -0
- package/build/@/components/ui/alert.d.ts +10 -0
- package/build/@/components/ui/alert.d.ts.map +1 -0
- package/build/@/components/ui/alert.js +26 -0
- package/build/@/components/ui/alert.js.map +1 -0
- package/build/@/components/ui/button.d.ts +11 -0
- package/build/@/components/ui/button.d.ts.map +1 -0
- package/build/@/components/ui/button.js +32 -0
- package/build/@/components/ui/button.js.map +1 -0
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/input.d.ts +4 -0
- package/build/@/components/ui/input.d.ts.map +1 -0
- package/build/@/components/ui/input.js +7 -0
- package/build/@/components/ui/input.js.map +1 -0
- package/build/@/components/ui/label.d.ts +5 -0
- package/build/@/components/ui/label.d.ts.map +1 -0
- package/build/@/components/ui/label.js +8 -0
- package/build/@/components/ui/label.js.map +1 -0
- package/build/@/lib/utils.d.ts +3 -0
- package/build/@/lib/utils.d.ts.map +1 -0
- package/build/@/lib/utils.js +6 -0
- package/build/@/lib/utils.js.map +1 -0
- package/build/append.d.ts +23 -0
- package/build/append.d.ts.map +1 -0
- package/build/append.js +49 -0
- package/build/append.js.map +1 -0
- package/build/components/connected.d.ts +8 -0
- package/build/components/connected.d.ts.map +1 -0
- package/build/components/connected.js +16 -0
- package/build/components/connected.js.map +1 -0
- package/build/components/credit.d.ts +5 -0
- package/build/components/credit.d.ts.map +1 -0
- package/build/components/credit.js +16 -0
- package/build/components/credit.js.map +1 -0
- package/build/components/fields.d.ts +23 -0
- package/build/components/fields.d.ts.map +1 -0
- package/build/components/fields.js +39 -0
- package/build/components/fields.js.map +1 -0
- package/build/components/index.d.ts +9 -0
- package/build/components/index.d.ts.map +1 -0
- package/build/components/index.js +8 -0
- package/build/components/index.js.map +1 -0
- package/build/components/offer.d.ts +9 -0
- package/build/components/offer.d.ts.map +1 -0
- package/build/components/offer.js +99 -0
- package/build/components/offer.js.map +1 -0
- package/build/components/panel.d.ts +12 -0
- package/build/components/panel.d.ts.map +1 -0
- package/build/components/panel.js +129 -0
- package/build/components/panel.js.map +1 -0
- package/build/components/route.d.ts +9 -0
- package/build/components/route.d.ts.map +1 -0
- package/build/components/route.js +17 -0
- package/build/components/route.js.map +1 -0
- package/build/components/screen.d.ts +17 -0
- package/build/components/screen.d.ts.map +1 -0
- package/build/components/screen.js +79 -0
- package/build/components/screen.js.map +1 -0
- package/build/components/types.d.ts +81 -0
- package/build/components/types.d.ts.map +1 -0
- package/build/components/types.js +2 -0
- package/build/components/types.js.map +1 -0
- package/build/consts.d.ts +37 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +43 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +10 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +14 -0
- package/build/entrypoints.js.map +1 -0
- package/build/hooks/consts.local.d.ts +4 -0
- package/build/hooks/consts.local.d.ts.map +1 -0
- package/build/hooks/consts.local.js +9 -0
- package/build/hooks/consts.local.js.map +1 -0
- package/build/hooks/types.d.ts +78 -0
- package/build/hooks/types.d.ts.map +1 -0
- package/build/hooks/types.js +2 -0
- package/build/hooks/types.js.map +1 -0
- package/build/hooks/use-email-login.d.ts +13 -0
- package/build/hooks/use-email-login.d.ts.map +1 -0
- package/build/hooks/use-email-login.js +208 -0
- package/build/hooks/use-email-login.js.map +1 -0
- package/build/hooks/use-password.d.ts +13 -0
- package/build/hooks/use-password.d.ts.map +1 -0
- package/build/hooks/use-password.js +118 -0
- package/build/hooks/use-password.js.map +1 -0
- package/build/hooks/use-translate.d.ts +7 -0
- package/build/hooks/use-translate.d.ts.map +1 -0
- package/build/hooks/use-translate.js +12 -0
- package/build/hooks/use-translate.js.map +1 -0
- package/build/i18n/auth/be.json +7 -0
- package/build/i18n/auth/de.json +7 -0
- package/build/i18n/auth/en.json +7 -0
- package/build/i18n/auth/es.json +7 -0
- package/build/i18n/auth/fr.json +7 -0
- package/build/i18n/auth/pl.json +7 -0
- package/build/i18n/auth/ru.json +7 -0
- package/build/i18n/auth/uk.json +7 -0
- package/build/i18n/be.json +100 -0
- package/build/i18n/de.json +100 -0
- package/build/i18n/en.json +100 -0
- package/build/i18n/es.json +100 -0
- package/build/i18n/fr.json +100 -0
- package/build/i18n/pl.json +100 -0
- package/build/i18n/ru.json +100 -0
- package/build/i18n/uk.json +100 -0
- package/build/i18n.d.ts +2 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +40 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +23 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +17 -0
- package/build/index.js.map +1 -0
- package/build/password/types.d.ts +39 -0
- package/build/password/types.d.ts.map +1 -0
- package/build/password/types.js +2 -0
- package/build/password/types.js.map +1 -0
- package/build/password.d.ts +4 -0
- package/build/password.d.ts.map +1 -0
- package/build/password.js +45 -0
- package/build/password.js.map +1 -0
- package/build/plugin.d.ts +10 -0
- package/build/plugin.d.ts.map +1 -0
- package/build/plugin.js +21 -0
- package/build/plugin.js.map +1 -0
- package/build/refusal/consts.local.d.ts +3 -0
- package/build/refusal/consts.local.d.ts.map +1 -0
- package/build/refusal/consts.local.js +3 -0
- package/build/refusal/consts.local.js.map +1 -0
- package/build/refusal/types.d.ts +38 -0
- package/build/refusal/types.d.ts.map +1 -0
- package/build/refusal/types.js +2 -0
- package/build/refusal/types.js.map +1 -0
- package/build/refusal.d.ts +4 -0
- package/build/refusal.d.ts.map +1 -0
- package/build/refusal.js +131 -0
- package/build/refusal.js.map +1 -0
- package/build/service.d.ts +12 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +66 -0
- package/build/service.js.map +1 -0
- package/build/state/types.d.ts +90 -0
- package/build/state/types.d.ts.map +1 -0
- package/build/state/types.js +2 -0
- package/build/state/types.js.map +1 -0
- package/build/state.d.ts +4 -0
- package/build/state.d.ts.map +1 -0
- package/build/state.js +98 -0
- package/build/state.js.map +1 -0
- package/build/step.d.ts +16 -0
- package/build/step.d.ts.map +1 -0
- package/build/step.js +31 -0
- package/build/step.js.map +1 -0
- package/build/storage/types.d.ts +42 -0
- package/build/storage/types.d.ts.map +1 -0
- package/build/storage/types.js +2 -0
- package/build/storage/types.js.map +1 -0
- package/build/storage.d.ts +4 -0
- package/build/storage.d.ts.map +1 -0
- package/build/storage.js +100 -0
- package/build/storage.js.map +1 -0
- package/build/types.d.ts +102 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/account.d.ts +4 -0
- package/build/utils/account.d.ts.map +1 -0
- package/build/utils/account.js +11 -0
- package/build/utils/account.js.map +1 -0
- package/build/utils/locale.d.ts +6 -0
- package/build/utils/locale.d.ts.map +1 -0
- package/build/utils/locale.js +10 -0
- package/build/utils/locale.js.map +1 -0
- package/build/utils/text/types.d.ts +23 -0
- package/build/utils/text/types.d.ts.map +1 -0
- package/build/utils/text/types.js +2 -0
- package/build/utils/text/types.js.map +1 -0
- package/build/utils/text.d.ts +4 -0
- package/build/utils/text.d.ts.map +1 -0
- package/build/utils/text.js +26 -0
- package/build/utils/text.js.map +1 -0
- package/build/utils.d.ts +8 -0
- package/build/utils.d.ts.map +1 -0
- package/build/utils.js +9 -0
- package/build/utils.js.map +1 -0
- package/components.json +21 -0
- package/package.json +71 -4
- package/src/@/components/ui/alert.tsx +70 -0
- package/src/@/components/ui/button.tsx +60 -0
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/input.tsx +22 -0
- package/src/@/components/ui/label.tsx +23 -0
- package/src/@/globals.css +57 -0
- package/src/@/lib/utils.ts +6 -0
- package/src/append.ts +65 -0
- package/src/components/connected.tsx +36 -0
- package/src/components/credit.tsx +30 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/index.ts +8 -0
- package/src/components/offer.tsx +197 -0
- package/src/components/panel.tsx +299 -0
- package/src/components/route.tsx +20 -0
- package/src/components/screen.tsx +253 -0
- package/src/components/types.ts +85 -0
- package/src/consts.ts +53 -0
- package/src/entrypoints.ts +15 -0
- package/src/hooks/consts.local.ts +12 -0
- package/src/hooks/types.ts +79 -0
- package/src/hooks/use-email-login.ts +234 -0
- package/src/hooks/use-password.ts +137 -0
- package/src/hooks/use-translate.ts +14 -0
- package/src/i18n/auth/be.json +7 -0
- package/src/i18n/auth/de.json +7 -0
- package/src/i18n/auth/en.json +7 -0
- package/src/i18n/auth/es.json +7 -0
- package/src/i18n/auth/fr.json +7 -0
- package/src/i18n/auth/pl.json +7 -0
- package/src/i18n/auth/ru.json +7 -0
- package/src/i18n/auth/uk.json +7 -0
- package/src/i18n/be.json +100 -0
- package/src/i18n/de.json +100 -0
- package/src/i18n/en.json +100 -0
- package/src/i18n/es.json +100 -0
- package/src/i18n/fr.json +100 -0
- package/src/i18n/pl.json +100 -0
- package/src/i18n/ru.json +100 -0
- package/src/i18n/uk.json +100 -0
- package/src/i18n.ts +43 -0
- package/src/index.ts +22 -0
- package/src/password/types.ts +43 -0
- package/src/password.ts +65 -0
- package/src/plugin.ts +22 -0
- package/src/refusal/consts.local.ts +2 -0
- package/src/refusal/types.ts +64 -0
- package/src/refusal.ts +145 -0
- package/src/service.ts +95 -0
- package/src/state/types.ts +72 -0
- package/src/state.ts +115 -0
- package/src/step.ts +38 -0
- package/src/storage/types.ts +43 -0
- package/src/storage.ts +113 -0
- package/src/types.ts +111 -0
- package/src/utils/account.ts +12 -0
- package/src/utils/locale.ts +11 -0
- package/src/utils/text/types.ts +24 -0
- package/src/utils/text.ts +34 -0
- package/src/utils.ts +9 -0
- package/tests/helpers.ts +22 -0
- package/tests/i18n.spec.ts +94 -0
- package/tests/package-boundary.spec.ts +35 -0
- package/tests/password.spec.ts +56 -0
- package/tests/refusal.spec.ts +91 -0
- package/tests/state.spec.ts +99 -0
- package/tests/step.spec.ts +82 -0
- package/tests/storage.spec.ts +83 -0
- package/tsconfig.json +21 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx'
|
|
2
|
+
import { twMerge } from 'tailwind-merge'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The class-name merger the components here are written against, exported so a replacement screen
|
|
6
|
+
* built on `useEmailLogin()` need not declare its own copy. Not a re-export of the private
|
|
7
|
+
* `src/@/lib/utils` — that tree is this package's own and is never exposed.
|
|
8
|
+
*/
|
|
9
|
+
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs))
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { EmailStorageArea } from '../src/storage/types.js'
|
|
2
|
+
|
|
3
|
+
/** A Web Storage area in memory; `failing` makes every call throw, as a browser refusing site data does. */
|
|
4
|
+
export const memoryArea = (failing = false): EmailStorageArea & { data: Map<string, string> } => {
|
|
5
|
+
const data = new Map<string, string>()
|
|
6
|
+
const guard = (): void => {
|
|
7
|
+
if (failing) {
|
|
8
|
+
throw new Error('SecurityError: storage is disabled')
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
data,
|
|
14
|
+
getItem: key => { guard(); return data.get(key) ?? null },
|
|
15
|
+
setItem: (key, value) => { guard(); data.set(key, value) },
|
|
16
|
+
removeItem: key => { guard(); data.delete(key) },
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Deterministic "random" bytes: 0, 1, 2, … from `start`. */
|
|
21
|
+
export const counting = (start = 0) => (count: number): Uint8Array =>
|
|
22
|
+
Uint8Array.from({ length: count }, (_, index) => (start + index) % 256)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import en from '../src/i18n/en.json' with { type: 'json' }
|
|
5
|
+
import pl from '../src/i18n/pl.json' with { type: 'json' }
|
|
6
|
+
import ru from '../src/i18n/ru.json' with { type: 'json' }
|
|
7
|
+
import be from '../src/i18n/be.json' with { type: 'json' }
|
|
8
|
+
import uk from '../src/i18n/uk.json' with { type: 'json' }
|
|
9
|
+
import es from '../src/i18n/es.json' with { type: 'json' }
|
|
10
|
+
import de from '../src/i18n/de.json' with { type: 'json' }
|
|
11
|
+
import fr from '../src/i18n/fr.json' with { type: 'json' }
|
|
12
|
+
import authEn from '../src/i18n/auth/en.json' with { type: 'json' }
|
|
13
|
+
import authPl from '../src/i18n/auth/pl.json' with { type: 'json' }
|
|
14
|
+
import authRu from '../src/i18n/auth/ru.json' with { type: 'json' }
|
|
15
|
+
import authBe from '../src/i18n/auth/be.json' with { type: 'json' }
|
|
16
|
+
import authUk from '../src/i18n/auth/uk.json' with { type: 'json' }
|
|
17
|
+
import authEs from '../src/i18n/auth/es.json' with { type: 'json' }
|
|
18
|
+
import authDe from '../src/i18n/auth/de.json' with { type: 'json' }
|
|
19
|
+
import authFr from '../src/i18n/auth/fr.json' with { type: 'json' }
|
|
20
|
+
import { EMAIL_AUTH_LOCALES } from '@owlmeans/auth-email'
|
|
21
|
+
|
|
22
|
+
const LANGUAGES: Record<string, unknown> = { en, pl, ru, be, uk, es, de, fr }
|
|
23
|
+
const AUTH: Record<string, { login: { method: { email: string } } }> = {
|
|
24
|
+
en: authEn, pl: authPl, ru: authRu, be: authBe, uk: authUk, es: authEs, de: authDe, fr: authFr,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every leaf key path in a nested translation object, e.g. `login.title`. */
|
|
28
|
+
const leaves = (value: unknown, prefix = ''): string[] => typeof value !== 'object' || value == null
|
|
29
|
+
? [prefix]
|
|
30
|
+
: Object.entries(value as Record<string, unknown>)
|
|
31
|
+
.flatMap(([key, child]) => leaves(child, prefix === '' ? key : `${prefix}.${key}`))
|
|
32
|
+
|
|
33
|
+
const valueOf = (resource: unknown, key: string): unknown =>
|
|
34
|
+
key.split('.').reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], resource)
|
|
35
|
+
|
|
36
|
+
const placeholders = (text: string): string[] => [...text.matchAll(/\{\{\s*([\w-]+)\s*\}\}/g)].map(m => m[1]).sort()
|
|
37
|
+
|
|
38
|
+
const sourceFiles = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
39
|
+
const path = join(dir, entry.name)
|
|
40
|
+
|
|
41
|
+
return entry.isDirectory() ? sourceFiles(path) : /\.tsx?$/.test(entry.name) ? [path] : []
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** Every `t('key', 'English default')` in the sources, with its default. */
|
|
45
|
+
const used = (): Map<string, string> => {
|
|
46
|
+
const result = new Map<string, string>()
|
|
47
|
+
const call = /\bt\(\s*'([^']+)'\s*,\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")/g
|
|
48
|
+
for (const file of sourceFiles(join(import.meta.dir, '../src'))) {
|
|
49
|
+
for (const match of readFileSync(file, 'utf8').matchAll(call)) {
|
|
50
|
+
result.set(match[1], (match[2] ?? match[3]).replace(/\\'/g, "'").replace(/\\"/g, '"'))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('@owlmeans/web-auth-email — translations', () => {
|
|
58
|
+
const english = leaves(en).sort()
|
|
59
|
+
|
|
60
|
+
test('ships exactly the eight sign-in languages', () => {
|
|
61
|
+
expect(Object.keys(LANGUAGES).sort()).toEqual([...EMAIL_AUTH_LOCALES].sort())
|
|
62
|
+
expect(Object.keys(AUTH).sort()).toEqual([...EMAIL_AUTH_LOCALES].sort())
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
for (const [lng, resource] of Object.entries(LANGUAGES)) {
|
|
66
|
+
test(`${lng}: the same keys as English, none empty, the same placeholders`, () => {
|
|
67
|
+
expect(leaves(resource).sort()).toEqual(english)
|
|
68
|
+
for (const key of english) {
|
|
69
|
+
const value = valueOf(resource, key)
|
|
70
|
+
expect(typeof value === 'string' && value.trim() !== '').toBe(true)
|
|
71
|
+
expect(placeholders(value as string)).toEqual(placeholders(valueOf(en, key) as string))
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test('every key a component asks for exists, and its English default is the bundle text', () => {
|
|
77
|
+
const keys = used()
|
|
78
|
+
// An empty scan would make the rest pass vacuously.
|
|
79
|
+
expect(keys.size).toBeGreaterThanOrEqual(60)
|
|
80
|
+
for (const [key, defaultValue] of keys) {
|
|
81
|
+
expect(valueOf(en, key)).toBe(defaultValue)
|
|
82
|
+
}
|
|
83
|
+
// …and nothing in the bundle is dead.
|
|
84
|
+
expect([...keys.keys()].sort()).toEqual(english)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('login.method.email reaches the auth resource in every language', () => {
|
|
88
|
+
for (const resource of Object.values(AUTH)) {
|
|
89
|
+
expect(leaves(resource)).toEqual(['login.method.email'])
|
|
90
|
+
expect(resource.login.method.email.trim()).not.toBe('')
|
|
91
|
+
}
|
|
92
|
+
expect(authEn.login.method.email).toBe('Continue with email')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const source = join(import.meta.dir, '../src')
|
|
6
|
+
|
|
7
|
+
const sourceFiles = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
8
|
+
const path = join(dir, entry.name)
|
|
9
|
+
|
|
10
|
+
return entry.isDirectory() ? sourceFiles(path) : [path]
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('@owlmeans/web-auth-email — package boundary', () => {
|
|
14
|
+
const files = sourceFiles(source).filter(file => /\.tsx?$/.test(file))
|
|
15
|
+
|
|
16
|
+
test('does not use consumer-project UI aliases in package source', () => {
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
expect(readFileSync(file, 'utf8')).not.toMatch(/from\s+['"]@\//)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('never depends on a panel package and never logs through console', () => {
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const text = readFileSync(file, 'utf8')
|
|
25
|
+
expect(text).not.toMatch(/from\s+['"]@owlmeans\/(web|mui|client)-panel/)
|
|
26
|
+
expect(text).not.toMatch(/\bconsole\./)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('never renders a natively disabled control', () => {
|
|
31
|
+
for (const file of files.filter(file => file.endsWith('.tsx') && !file.includes('/@/'))) {
|
|
32
|
+
expect(readFileSync(file, 'utf8')).not.toMatch(/\sdisabled=\{/)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { EMAIL_PASSWORD_MAX_LENGTH, EMAIL_PASSWORD_MIN_LENGTH, EmailPasswordPurpose } from '@owlmeans/auth-email'
|
|
3
|
+
import { passwordPanelHelper as helper } from '../src/password.js'
|
|
4
|
+
|
|
5
|
+
describe('passwordPanelHelper — state and actions', () => {
|
|
6
|
+
test('loading, unreadable, off, unset and set', () => {
|
|
7
|
+
expect(helper.stateOf(null, true)).toBe('loading')
|
|
8
|
+
expect(helper.stateOf(null, false)).toBe('unreadable')
|
|
9
|
+
expect(helper.stateOf({ enabled: false, set: false, declined: false }, false)).toBe('off')
|
|
10
|
+
expect(helper.stateOf({ enabled: true, set: false, declined: true }, false)).toBe('unset')
|
|
11
|
+
expect(helper.stateOf({ enabled: true, set: true, declined: false }, true)).toBe('set')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('set without a password; change and remove with one; nothing when off or unknown', () => {
|
|
15
|
+
expect(helper.actionsOf({ enabled: true, set: false, declined: false })).toEqual(['set'])
|
|
16
|
+
expect(helper.actionsOf({ enabled: true, set: true, declined: false })).toEqual(['change', 'remove'])
|
|
17
|
+
expect(helper.actionsOf({ enabled: false, set: true, declined: false })).toEqual([])
|
|
18
|
+
expect(helper.actionsOf(null)).toEqual([])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('each action asks for its own confirmation purpose', () => {
|
|
22
|
+
expect(helper.purposeOf('set')).toBe(EmailPasswordPurpose.Set)
|
|
23
|
+
expect(helper.purposeOf('change')).toBe(EmailPasswordPurpose.Change)
|
|
24
|
+
expect(helper.purposeOf('remove')).toBe(EmailPasswordPurpose.Remove)
|
|
25
|
+
expect(helper.needsPassword('remove')).toBe(false)
|
|
26
|
+
expect(helper.needsPassword('change')).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('passwordPanelHelper — what stops a confirmation', () => {
|
|
31
|
+
const good = 'a'.repeat(EMAIL_PASSWORD_MIN_LENGTH)
|
|
32
|
+
|
|
33
|
+
test('the PIN first, then the password: empty, out of bounds, not repeated', () => {
|
|
34
|
+
expect(helper.confirmBlock('set', { pin: '12345', password: good, repeat: good })).toEqual({ kind: 'pin-format' })
|
|
35
|
+
expect(helper.confirmBlock('set', { pin: '123456', password: '', repeat: '' })).toEqual({ kind: 'password-empty' })
|
|
36
|
+
expect(helper.confirmBlock('set', { pin: '123456', password: 'short', repeat: 'short' })).toEqual({ kind: 'weak' })
|
|
37
|
+
const long = 'a'.repeat(EMAIL_PASSWORD_MAX_LENGTH + 1)
|
|
38
|
+
expect(helper.confirmBlock('change', { pin: '123456', password: long, repeat: long })).toEqual({ kind: 'weak' })
|
|
39
|
+
expect(helper.confirmBlock('change', { pin: '123456', password: good, repeat: `${good}!` })).toEqual({ kind: 'mismatch' })
|
|
40
|
+
expect(helper.confirmBlock('change', { pin: '123456', password: good, repeat: good })).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('remove needs the PIN alone', () => {
|
|
44
|
+
expect(helper.confirmBlock('remove', { pin: '123456', password: '', repeat: '' })).toBeNull()
|
|
45
|
+
expect(helper.confirmBlock('remove', { pin: 'abcdef', password: '', repeat: '' })).toEqual({ kind: 'pin-format' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('length counts NFKC, as the server does', () => {
|
|
49
|
+
// Eight full-width letters NFKC-fold to eight ASCII ones — still eight.
|
|
50
|
+
const wide = 'ABCDEFGH'
|
|
51
|
+
expect(helper.passwordBlock(wide, wide)).toBeNull()
|
|
52
|
+
// "ff" (one ligature) folds to "ff" (two): seven ligatures read as fourteen characters.
|
|
53
|
+
const ligatures = 'ff'.repeat(7)
|
|
54
|
+
expect(helper.passwordBlock(ligatures, ligatures)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AuthenFailed, AuthUnavailable } from '@owlmeans/auth'
|
|
3
|
+
import { ApiClientError, ApiStatusError, ServerAuthError } from '@owlmeans/api'
|
|
4
|
+
import { ResilientError } from '@owlmeans/error'
|
|
5
|
+
import { EmailOfferExpired, EmailPasswordDisabled, EmailPasswordWeak } from '@owlmeans/auth-email'
|
|
6
|
+
import { CaptchaLoadError } from '@owlmeans/web-captcha'
|
|
7
|
+
import { emailRefusalHelper as helper } from '../src/refusal.js'
|
|
8
|
+
import type { Translate } from '../src/types.js'
|
|
9
|
+
|
|
10
|
+
const english: Translate = (_key, defaultValue) => defaultValue
|
|
11
|
+
|
|
12
|
+
/** What the API client rejects with for a production answer: the status alone. */
|
|
13
|
+
const status = (code: number) => Object.assign(code === 401 ? new ServerAuthError() : code === 403 ? new ApiClientError('forbidden') : new ApiStatusError(code), {
|
|
14
|
+
responseStatus: code,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** What it rejects with for a development answer: the typed error, rebuilt from its wire form. */
|
|
18
|
+
const wire = (error: Error) => ResilientError.ensure(ResilientError.marshal(error).message, true)
|
|
19
|
+
|
|
20
|
+
describe('emailRefusalHelper — typed refusals (development bodies)', () => {
|
|
21
|
+
test('wrong password, wrong code, captcha and its outage', () => {
|
|
22
|
+
expect(helper.refusalOf(wire(new AuthenFailed('email:password')), 'password').kind).toBe('credentials')
|
|
23
|
+
expect(helper.refusalOf(wire(new AuthenFailed('otp:code')), 'pin').kind).toBe('code')
|
|
24
|
+
expect(helper.refusalOf(wire(new AuthenFailed('recaptcha:score')), 'send').kind).toBe('captcha')
|
|
25
|
+
expect(helper.refusalOf(wire(new AuthUnavailable('recaptcha:config')), 'send').kind).toBe('captcha-unavailable')
|
|
26
|
+
expect(helper.refusalOf(new CaptchaLoadError('timeout'), 'send').kind).toBe('captcha-unavailable')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('the e-mail family: weak, expired offer, passwords off', () => {
|
|
30
|
+
expect(helper.refusalOf(wire(new EmailPasswordWeak()), 'set').kind).toBe('weak')
|
|
31
|
+
expect(helper.refusalOf(wire(new EmailOfferExpired()), 'offer').kind).toBe('offer-expired')
|
|
32
|
+
expect(helper.refusalOf(wire(new EmailPasswordDisabled()), 'password').kind).toBe('password-disabled')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a throttle names its wait only when one is known', () => {
|
|
36
|
+
const throttled = Object.assign(new ResilientError('OtpErrorThrottled', 'otp:throttled'), { retryAfter: 45 })
|
|
37
|
+
expect(helper.refusalOf(throttled, 'send')).toEqual({ kind: 'throttled', retryAfter: 45 })
|
|
38
|
+
// Rebuilt from the wire the number is lost (1) — no time is claimed then.
|
|
39
|
+
expect(helper.refusalOf(Object.assign(status(429), { retryAfter: 1 }), 'send')).toEqual({ kind: 'throttled' })
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('emailRefusalHelper — status-only refusals (production bodies)', () => {
|
|
44
|
+
test('a 401 is a wrong code after a PIN and wrong credentials after a password', () => {
|
|
45
|
+
expect(helper.refusalOf(status(401), 'pin').kind).toBe('code')
|
|
46
|
+
expect(helper.refusalOf(status(401), 'password').kind).toBe('credentials')
|
|
47
|
+
expect(helper.refusalOf(status(401), 'send').kind).toBe('failed')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a 403 is the captcha on a guest route and the confirmation PIN on a signed-in one', () => {
|
|
51
|
+
expect(helper.refusalOf(status(403), 'send').kind).toBe('captcha')
|
|
52
|
+
expect(helper.refusalOf(status(403), 'password').kind).toBe('captcha')
|
|
53
|
+
expect(helper.refusalOf(status(403), 'confirm').kind).toBe('code')
|
|
54
|
+
expect(helper.refusalOf(status(403), 'set').kind).toBe('code')
|
|
55
|
+
expect(helper.refusalOf(status(403), 'remove').kind).toBe('code')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('the server\'s own refusal classes, unknown here, still read by their message', () => {
|
|
59
|
+
const credential = new ResilientError('EmailCredentialRefused', 'auth:manager:authen:email:password')
|
|
60
|
+
const confirmation = new ResilientError('EmailVerificationRefused', 'auth:manager:authen:email:confirm:purpose')
|
|
61
|
+
expect(helper.refusalOf(Object.assign(credential, { responseStatus: 401 }), 'password').kind).toBe('credentials')
|
|
62
|
+
expect(helper.refusalOf(Object.assign(confirmation, { responseStatus: 403 }), 'remove').kind).toBe('code')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('429, 422, 409, 503, 404, 400 and anything else', () => {
|
|
66
|
+
expect(helper.refusalOf(status(429), 'pin').kind).toBe('throttled')
|
|
67
|
+
expect(helper.refusalOf(status(422), 'set').kind).toBe('weak')
|
|
68
|
+
expect(helper.refusalOf(status(409), 'offer').kind).toBe('offer-expired')
|
|
69
|
+
expect(helper.refusalOf(status(503), 'send').kind).toBe('unavailable')
|
|
70
|
+
expect(helper.refusalOf(status(404), 'password').kind).toBe('password-disabled')
|
|
71
|
+
expect(helper.refusalOf(status(400), 'send').kind).toBe('email')
|
|
72
|
+
expect(helper.refusalOf(status(500), 'pin').kind).toBe('failed')
|
|
73
|
+
expect(helper.refusalOf(new Error('network'), 'pin').kind).toBe('failed')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('emailRefusalHelper — messages', () => {
|
|
78
|
+
test('a known wait is phrased by the language itself', () => {
|
|
79
|
+
expect(helper.messageOf({ kind: 'throttled', retryAfter: 45 }, english, 'en'))
|
|
80
|
+
.toBe('Too many attempts. Please try again in 45 seconds.')
|
|
81
|
+
expect(helper.messageOf({ kind: 'throttled', retryAfter: 600 }, english, 'en'))
|
|
82
|
+
.toBe('Too many attempts. Please try again in 10 minutes.')
|
|
83
|
+
expect(helper.messageOf({ kind: 'throttled' }, english)).toBe('Too many attempts. Please wait a little and try again.')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('the policy and the code length fill their placeholders, whatever the translator', () => {
|
|
87
|
+
const raw: Translate = key => key === 'error.weak' ? 'Od {{min}} do {{max}}' : key
|
|
88
|
+
expect(helper.messageOf({ kind: 'weak' }, raw)).toBe('Od 8 do 128')
|
|
89
|
+
expect(helper.messageOf({ kind: 'pin-format' }, english)).toBe('Enter the code from the email: 6 digits.')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { EmailAuthOptionsView } from '@owlmeans/auth-email'
|
|
3
|
+
import { emailLoginStateHelper as helper } from '../src/state.js'
|
|
4
|
+
import type { EmailLoginAction, EmailLoginState } from '../src/state/types.js'
|
|
5
|
+
|
|
6
|
+
const OPTIONS: EmailAuthOptionsView = { password: true, captcha: null, pinLength: 6 }
|
|
7
|
+
|
|
8
|
+
const run = (...actions: EmailLoginAction[]): EmailLoginState =>
|
|
9
|
+
actions.reduce(helper.reduce, helper.initial())
|
|
10
|
+
|
|
11
|
+
describe('emailLoginStateHelper — the PIN path', () => {
|
|
12
|
+
test('address → send → PIN step with the challenge → signed in', () => {
|
|
13
|
+
let state = run({ type: 'options', options: OPTIONS }, { type: 'email', value: ' Person@Example.COM ' })
|
|
14
|
+
expect(helper.sendBlock(state)).toBeNull()
|
|
15
|
+
expect(helper.normalizeEmail(state.email)).toBe('person@example.com')
|
|
16
|
+
|
|
17
|
+
state = helper.reduce(state, { type: 'start', busy: 'send' })
|
|
18
|
+
expect(state.busy).toBe('send')
|
|
19
|
+
state = helper.reduce(state, { type: 'sent', challenge: 'challenge-1' })
|
|
20
|
+
expect(state).toMatchObject({ step: 'pin', challenge: 'challenge-1', notice: 'sent', busy: null })
|
|
21
|
+
|
|
22
|
+
state = helper.reduce(state, { type: 'pin', value: '12 34-56 78' })
|
|
23
|
+
expect(state.pin).toBe('123456')
|
|
24
|
+
expect(helper.verifyBlock(state)).toBeNull()
|
|
25
|
+
|
|
26
|
+
state = helper.reduce(helper.reduce(state, { type: 'start', busy: 'sign-in' }), { type: 'signed-in' })
|
|
27
|
+
expect(state).toMatchObject({ step: 'done', pin: '', password: '', busy: null })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('nothing is sent for an invalid address or an incomplete code', () => {
|
|
31
|
+
expect(helper.sendBlock(run({ type: 'email', value: 'no-at-sign' }))).toEqual({ kind: 'email' })
|
|
32
|
+
expect(helper.sendBlock(run({ type: 'email', value: `${'a'.repeat(250)}@example.com` }))).toEqual({ kind: 'email' })
|
|
33
|
+
|
|
34
|
+
const state = run({ type: 'email', value: 'a@b.co' }, { type: 'sent', challenge: 'c' }, { type: 'pin', value: '123' })
|
|
35
|
+
expect(helper.verifyBlock(state)).toEqual({ kind: 'pin-format' })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('a wrong code clears the PIN and keeps the challenge; "Change email" forgets both', () => {
|
|
39
|
+
let state = run({ type: 'email', value: 'a@b.co' }, { type: 'sent', challenge: 'c' }, { type: 'pin', value: '111111' })
|
|
40
|
+
state = helper.reduce(state, { type: 'refused', refusal: { kind: 'code' } })
|
|
41
|
+
expect(state).toMatchObject({ step: 'pin', pin: '', challenge: 'c', refusal: { kind: 'code' } })
|
|
42
|
+
|
|
43
|
+
state = helper.reduce(state, { type: 'pin', value: '2' })
|
|
44
|
+
expect(state.refusal).toBeNull()
|
|
45
|
+
|
|
46
|
+
state = helper.reduce(state, { type: 'change-email' })
|
|
47
|
+
expect(state).toMatchObject({ step: 'email', pin: '', challenge: null, notice: null })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a second action while one is in flight changes nothing', () => {
|
|
51
|
+
const busy = run({ type: 'options', options: OPTIONS }, { type: 'email', value: 'a@b.co' }, { type: 'start', busy: 'send' })
|
|
52
|
+
|
|
53
|
+
expect(helper.reduce(busy, { type: 'start', busy: 'sign-in' })).toBe(busy)
|
|
54
|
+
expect(helper.reduce(busy, { type: 'choose-password' })).toBe(busy)
|
|
55
|
+
expect(helper.reduce(busy, { type: 'change-email' })).toBe(busy)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('emailLoginStateHelper — the password path', () => {
|
|
60
|
+
test('"Use it" exists only where the deployment offers passwords', () => {
|
|
61
|
+
const off = run({ type: 'options', options: { ...OPTIONS, password: false } }, { type: 'choose-password' })
|
|
62
|
+
expect(off.step).toBe('email')
|
|
63
|
+
expect(helper.passwordOffered(off)).toBe(false)
|
|
64
|
+
|
|
65
|
+
const on = run({ type: 'options', options: OPTIONS }, { type: 'choose-password' })
|
|
66
|
+
expect(on.step).toBe('password')
|
|
67
|
+
expect(helper.signInBlock(on)).toEqual({ kind: 'email' })
|
|
68
|
+
expect(helper.signInBlock(helper.reduce(helper.reduce(on, { type: 'email', value: 'a@b.co' }),
|
|
69
|
+
{ type: 'password', value: '' }))).toEqual({ kind: 'password-empty' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('an unknown device turns into the PIN step, with the notice, and drops the password', () => {
|
|
73
|
+
const state = run(
|
|
74
|
+
{ type: 'options', options: OPTIONS }, { type: 'choose-password' }, { type: 'email', value: 'a@b.co' },
|
|
75
|
+
{ type: 'password', value: 'secret-password' }, { type: 'reveal', value: true },
|
|
76
|
+
{ type: 'start', busy: 'sign-in' }, { type: 'device', challenge: 'device-challenge' },
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
expect(state).toMatchObject({
|
|
80
|
+
step: 'pin', notice: 'device', challenge: 'device-challenge', password: '', reveal: false, busy: null,
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('wrong credentials clear the password and keep the step; the options turning passwords off close it', () => {
|
|
85
|
+
let state = run({ type: 'options', options: OPTIONS }, { type: 'choose-password' }, { type: 'email', value: 'a@b.co' },
|
|
86
|
+
{ type: 'password', value: 'wrong' }, { type: 'refused', refusal: { kind: 'credentials' } })
|
|
87
|
+
expect(state).toMatchObject({ step: 'password', password: '', refusal: { kind: 'credentials' } })
|
|
88
|
+
|
|
89
|
+
state = helper.reduce(state, { type: 'options', options: { ...OPTIONS, password: false } })
|
|
90
|
+
expect(state.step).toBe('email')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('a failure after "signed in" sends the person back to the address', () => {
|
|
94
|
+
const state = run({ type: 'email', value: 'a@b.co' }, { type: 'sent', challenge: 'c' }, { type: 'signed-in' },
|
|
95
|
+
{ type: 'refused', refusal: { kind: 'failed' } })
|
|
96
|
+
|
|
97
|
+
expect(state).toMatchObject({ step: 'email', challenge: null, refusal: { kind: 'failed' } })
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { DEFAULT_ALIAS as AUTH_SERVICE } from '@owlmeans/client-auth'
|
|
3
|
+
import type { LoginContext } from '@owlmeans/client-auth/login'
|
|
4
|
+
import type { EmailPasswordStatusView } from '@owlmeans/auth-email'
|
|
5
|
+
import { EMAIL_OFFER_LOGIN_STEP, EMAIL_OFFER_STEP_PRIORITY } from '../src/consts.js'
|
|
6
|
+
import { emailOfferStep } from '../src/step.js'
|
|
7
|
+
import { createEmailStorageHelper } from '../src/storage.js'
|
|
8
|
+
import { memoryArea } from './helpers.js'
|
|
9
|
+
|
|
10
|
+
const context = (userId: string | null): LoginContext => ({
|
|
11
|
+
service: (alias: string) => {
|
|
12
|
+
if (alias !== AUTH_SERVICE) {
|
|
13
|
+
throw new Error(`unexpected service ${alias}`)
|
|
14
|
+
}
|
|
15
|
+
return { user: () => { if (userId == null) throw new Error('signed out'); return { userId } } } as never
|
|
16
|
+
},
|
|
17
|
+
}) as unknown as LoginContext
|
|
18
|
+
|
|
19
|
+
const client = (status: EmailPasswordStatusView | Error) => {
|
|
20
|
+
let calls = 0
|
|
21
|
+
return {
|
|
22
|
+
calls: () => calls,
|
|
23
|
+
passwordStatus: async () => { calls++; if (status instanceof Error) throw status; return status },
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const OPEN: EmailPasswordStatusView = { enabled: true, set: false, declined: false }
|
|
28
|
+
|
|
29
|
+
describe('emailOfferStep — pending', () => {
|
|
30
|
+
test('sits below marketing consent and names the offer screen', () => {
|
|
31
|
+
const step = emailOfferStep(client(OPEN), 'auth-email:offer', createEmailStorageHelper({ session: () => memoryArea() }))
|
|
32
|
+
|
|
33
|
+
expect(step.alias).toBe(EMAIL_OFFER_LOGIN_STEP)
|
|
34
|
+
expect(step.entrypoint).toBe('auth-email:offer')
|
|
35
|
+
expect(step.priority).toBe(EMAIL_OFFER_STEP_PRIORITY)
|
|
36
|
+
expect(EMAIL_OFFER_STEP_PRIORITY).toBeLessThan(0)
|
|
37
|
+
expect(step.required).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('no ticket: not pending, and the status is never read', async () => {
|
|
41
|
+
const reader = client(OPEN)
|
|
42
|
+
const step = emailOfferStep(reader, 'offer', createEmailStorageHelper({ session: () => memoryArea() }))
|
|
43
|
+
|
|
44
|
+
expect(await step.pending(context('account-1'))).toBe(false)
|
|
45
|
+
expect(reader.calls()).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a ticket and an enabled, unset, undeclined password: pending', async () => {
|
|
49
|
+
const session = memoryArea()
|
|
50
|
+
const storage = createEmailStorageHelper({ session: () => session })
|
|
51
|
+
storage.keepOffer('ticket', 'account-1')
|
|
52
|
+
|
|
53
|
+
expect(await emailOfferStep(client(OPEN), 'offer', storage).pending(context('account-1'))).toBe(true)
|
|
54
|
+
expect(storage.offer('account-1')).toBe('ticket')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a status that no longer allows the offer: not pending, and the ticket is forgotten', async () => {
|
|
58
|
+
for (const status of [
|
|
59
|
+
{ ...OPEN, enabled: false }, { ...OPEN, set: true }, { ...OPEN, declined: true },
|
|
60
|
+
]) {
|
|
61
|
+
const session = memoryArea()
|
|
62
|
+
const storage = createEmailStorageHelper({ session: () => session })
|
|
63
|
+
storage.keepOffer('ticket', 'account-1')
|
|
64
|
+
expect(storage.offer('account-1')).toBe('ticket')
|
|
65
|
+
|
|
66
|
+
expect(await emailOfferStep(client(status), 'offer', storage).pending(context('account-1'))).toBe(false)
|
|
67
|
+
expect(storage.offer()).toBeNull()
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('another account\'s ticket is not this sign-in\'s offer; a failing read throws for the host to fail open', async () => {
|
|
72
|
+
const session = memoryArea()
|
|
73
|
+
const storage = createEmailStorageHelper({ session: () => session })
|
|
74
|
+
storage.keepOffer('ticket', 'account-1')
|
|
75
|
+
|
|
76
|
+
expect(await emailOfferStep(client(OPEN), 'offer', storage).pending(context('account-2'))).toBe(false)
|
|
77
|
+
await expect(emailOfferStep(client(new Error('down')), 'offer', storage).pending(context('account-1')))
|
|
78
|
+
.rejects.toThrow('down')
|
|
79
|
+
// The ticket survives a broken read — it may still be used once the status answers.
|
|
80
|
+
expect(storage.offer('account-1')).toBe('ticket')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { EMAIL_DEVICE_ID_PATTERN, EMAIL_OFFER_TTL_SECONDS } from '@owlmeans/auth-email'
|
|
3
|
+
import { EMAIL_DEVICE_STORAGE, EMAIL_OFFER_STORAGE } from '../src/consts.js'
|
|
4
|
+
import { createEmailStorageHelper } from '../src/storage.js'
|
|
5
|
+
import { counting, memoryArea } from './helpers.js'
|
|
6
|
+
|
|
7
|
+
const valid = new RegExp(EMAIL_DEVICE_ID_PATTERN)
|
|
8
|
+
|
|
9
|
+
describe('emailStorageHelper — device id', () => {
|
|
10
|
+
test('creates 16 random bytes as 22 base64url characters, kept in localStorage', () => {
|
|
11
|
+
const local = memoryArea()
|
|
12
|
+
const helper = createEmailStorageHelper({ local: () => local, random: counting(250) })
|
|
13
|
+
|
|
14
|
+
const id = helper.deviceId()
|
|
15
|
+
expect(id).toHaveLength(22)
|
|
16
|
+
expect(id).toMatch(valid)
|
|
17
|
+
expect(id).not.toMatch(/[+/=]/)
|
|
18
|
+
expect(local.data.get(EMAIL_DEVICE_STORAGE)).toBe(id)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('reuses the stored id, across helpers (a later page load)', () => {
|
|
22
|
+
const local = memoryArea()
|
|
23
|
+
const first = createEmailStorageHelper({ local: () => local, random: counting(1) }).deviceId()
|
|
24
|
+
const again = createEmailStorageHelper({ local: () => local, random: counting(99) }).deviceId()
|
|
25
|
+
|
|
26
|
+
expect(again).toBe(first)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('replaces a stored value that is not a device id', () => {
|
|
30
|
+
const local = memoryArea()
|
|
31
|
+
local.data.set(EMAIL_DEVICE_STORAGE, 'not a device id')
|
|
32
|
+
const id = createEmailStorageHelper({ local: () => local, random: counting(7) }).deviceId()
|
|
33
|
+
|
|
34
|
+
expect(id).toMatch(valid)
|
|
35
|
+
expect(local.data.get(EMAIL_DEVICE_STORAGE)).toBe(id)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('without storage, one ephemeral id for the life of the page', () => {
|
|
39
|
+
let calls = 0
|
|
40
|
+
const random = (count: number) => { calls++; return counting(calls)(count) }
|
|
41
|
+
const refusing = createEmailStorageHelper({ local: () => memoryArea(true), random })
|
|
42
|
+
const absent = createEmailStorageHelper({ local: () => null, random })
|
|
43
|
+
|
|
44
|
+
expect(refusing.deviceId()).toMatch(valid)
|
|
45
|
+
expect(refusing.deviceId()).toBe(refusing.deviceId())
|
|
46
|
+
expect(absent.deviceId()).toBe(absent.deviceId())
|
|
47
|
+
expect(calls).toBe(2)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('emailStorageHelper — offer ticket', () => {
|
|
52
|
+
test('keeps, reads and drops the ticket in sessionStorage', () => {
|
|
53
|
+
const session = memoryArea()
|
|
54
|
+
const helper = createEmailStorageHelper({ session: () => session })
|
|
55
|
+
|
|
56
|
+
expect(helper.offer()).toBeNull()
|
|
57
|
+
helper.keepOffer('ticket-1', 'account-1')
|
|
58
|
+
expect(session.data.has(EMAIL_OFFER_STORAGE)).toBe(true)
|
|
59
|
+
expect(helper.offer('account-1')).toBe('ticket-1')
|
|
60
|
+
expect(helper.offer()).toBe('ticket-1')
|
|
61
|
+
helper.dropOffer()
|
|
62
|
+
expect(helper.offer('account-1')).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('an expired ticket, or another account\'s, is no offer', () => {
|
|
66
|
+
const session = memoryArea()
|
|
67
|
+
let now = 1_000_000
|
|
68
|
+
const helper = createEmailStorageHelper({ session: () => session, now: () => now })
|
|
69
|
+
|
|
70
|
+
helper.keepOffer('ticket-2', 'account-2')
|
|
71
|
+
expect(helper.offer('someone-else')).toBeNull()
|
|
72
|
+
now += EMAIL_OFFER_TTL_SECONDS * 1000
|
|
73
|
+
expect(helper.offer('account-2')).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a browser that refuses storage keeps no offer and throws nothing', () => {
|
|
77
|
+
const helper = createEmailStorageHelper({ session: () => memoryArea(true) })
|
|
78
|
+
|
|
79
|
+
expect(() => helper.keepOffer('ticket-3')).not.toThrow()
|
|
80
|
+
expect(helper.offer()).toBeNull()
|
|
81
|
+
expect(() => helper.dropOffer()).not.toThrow()
|
|
82
|
+
})
|
|
83
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.react.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/",
|
|
9
|
+
"paths": {
|
|
10
|
+
"@/*": [
|
|
11
|
+
"./src/@/*"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"exclude": [
|
|
16
|
+
"./dist/**/*",
|
|
17
|
+
"./build/**/*",
|
|
18
|
+
"./tests/**/*",
|
|
19
|
+
"./*.ts"
|
|
20
|
+
]
|
|
21
|
+
}
|