@opensaas/stack-core 0.1.0
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/.turbo/turbo-build.log +4 -0
- package/README.md +447 -0
- package/dist/access/engine.d.ts +73 -0
- package/dist/access/engine.d.ts.map +1 -0
- package/dist/access/engine.js +244 -0
- package/dist/access/engine.js.map +1 -0
- package/dist/access/field-transforms.d.ts +47 -0
- package/dist/access/field-transforms.d.ts.map +1 -0
- package/dist/access/field-transforms.js +2 -0
- package/dist/access/field-transforms.js.map +1 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/types.d.ts +83 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/types.js +2 -0
- package/dist/access/types.js.map +1 -0
- package/dist/config/index.d.ts +39 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +413 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/context/index.d.ts +31 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +524 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/nested-operations.d.ts +10 -0
- package/dist/context/nested-operations.d.ts.map +1 -0
- package/dist/context/nested-operations.js +261 -0
- package/dist/context/nested-operations.js.map +1 -0
- package/dist/fields/index.d.ts +78 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +381 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/hooks/index.d.ts +58 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +79 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/case-utils.d.ts +49 -0
- package/dist/lib/case-utils.d.ts.map +1 -0
- package/dist/lib/case-utils.js +68 -0
- package/dist/lib/case-utils.js.map +1 -0
- package/dist/lib/case-utils.test.d.ts +2 -0
- package/dist/lib/case-utils.test.d.ts.map +1 -0
- package/dist/lib/case-utils.test.js +101 -0
- package/dist/lib/case-utils.test.js.map +1 -0
- package/dist/utils/password.d.ts +81 -0
- package/dist/utils/password.d.ts.map +1 -0
- package/dist/utils/password.js +132 -0
- package/dist/utils/password.js.map +1 -0
- package/dist/validation/schema.d.ts +17 -0
- package/dist/validation/schema.d.ts.map +1 -0
- package/dist/validation/schema.js +42 -0
- package/dist/validation/schema.js.map +1 -0
- package/dist/validation/schema.test.d.ts +2 -0
- package/dist/validation/schema.test.d.ts.map +1 -0
- package/dist/validation/schema.test.js +143 -0
- package/dist/validation/schema.test.js.map +1 -0
- package/docs/type-distribution-fix.md +136 -0
- package/package.json +48 -0
- package/src/access/engine.ts +360 -0
- package/src/access/field-transforms.ts +99 -0
- package/src/access/index.ts +20 -0
- package/src/access/types.ts +103 -0
- package/src/config/index.ts +71 -0
- package/src/config/types.ts +478 -0
- package/src/context/index.ts +814 -0
- package/src/context/nested-operations.ts +412 -0
- package/src/fields/index.ts +438 -0
- package/src/hooks/index.ts +132 -0
- package/src/index.ts +62 -0
- package/src/lib/case-utils.test.ts +127 -0
- package/src/lib/case-utils.ts +74 -0
- package/src/utils/password.ts +147 -0
- package/src/validation/schema.test.ts +171 -0
- package/src/validation/schema.ts +59 -0
- package/tests/access-relationships.test.ts +613 -0
- package/tests/access.test.ts +499 -0
- package/tests/config.test.ts +195 -0
- package/tests/context.test.ts +248 -0
- package/tests/hooks.test.ts +417 -0
- package/tests/password-type-distribution.test.ts +155 -0
- package/tests/password-types.test.ts +147 -0
- package/tests/password.test.ts +249 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { config, list, getContext } from '../src/index.js'
|
|
3
|
+
import { text, password, relationship } from '../src/fields/index.js'
|
|
4
|
+
import { PrismaClient } from '@prisma/client'
|
|
5
|
+
import type { HashedPassword } from '../src/utils/password.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Test to verify that password field transformation doesn't cause union type distribution
|
|
9
|
+
* This was the bug: all fields were becoming `string | HashedPassword` instead of just password field
|
|
10
|
+
*/
|
|
11
|
+
describe('Password Field Type Distribution Bug Fix', () => {
|
|
12
|
+
const mockPrismaClient = {
|
|
13
|
+
user: {
|
|
14
|
+
findUnique: async () => ({
|
|
15
|
+
id: '1',
|
|
16
|
+
name: 'John Doe',
|
|
17
|
+
email: 'john@example.com',
|
|
18
|
+
password: '$2a$10$hashedpassword',
|
|
19
|
+
}),
|
|
20
|
+
findMany: async () => [
|
|
21
|
+
{
|
|
22
|
+
id: '1',
|
|
23
|
+
name: 'John Doe',
|
|
24
|
+
email: 'john@example.com',
|
|
25
|
+
password: '$2a$10$hashedpassword',
|
|
26
|
+
posts: [
|
|
27
|
+
{
|
|
28
|
+
id: 'post1',
|
|
29
|
+
title: 'Test Post',
|
|
30
|
+
authorId: '1',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
post: {
|
|
37
|
+
findMany: async () => [],
|
|
38
|
+
},
|
|
39
|
+
} as unknown as PrismaClient
|
|
40
|
+
|
|
41
|
+
const testConfig = config({
|
|
42
|
+
db: {
|
|
43
|
+
provider: 'sqlite',
|
|
44
|
+
url: 'file:./test.db',
|
|
45
|
+
},
|
|
46
|
+
lists: {
|
|
47
|
+
User: list({
|
|
48
|
+
fields: {
|
|
49
|
+
name: text({ validation: { isRequired: true } }),
|
|
50
|
+
email: text({ validation: { isRequired: true } }),
|
|
51
|
+
password: password({ validation: { isRequired: true } }),
|
|
52
|
+
posts: relationship({ ref: 'Post.author', many: true }),
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
Post: list({
|
|
56
|
+
fields: {
|
|
57
|
+
title: text({ validation: { isRequired: true } }),
|
|
58
|
+
author: relationship({ ref: 'User.posts' }),
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should NOT make all fields a union of string | HashedPassword', async () => {
|
|
65
|
+
const context = getContext(testConfig, mockPrismaClient, null)
|
|
66
|
+
|
|
67
|
+
const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
68
|
+
|
|
69
|
+
if (user) {
|
|
70
|
+
// Type assertions that should compile correctly
|
|
71
|
+
// These would fail if the bug exists (type would be string | HashedPassword for all fields)
|
|
72
|
+
|
|
73
|
+
// name should be string, not string | HashedPassword
|
|
74
|
+
const name: string = user.name
|
|
75
|
+
expect(typeof name).toBe('string')
|
|
76
|
+
|
|
77
|
+
// email should be string, not string | HashedPassword
|
|
78
|
+
const email: string = user.email
|
|
79
|
+
expect(typeof email).toBe('string')
|
|
80
|
+
|
|
81
|
+
// id should be string, not string | HashedPassword
|
|
82
|
+
const id: string = user.id
|
|
83
|
+
expect(typeof id).toBe('string')
|
|
84
|
+
|
|
85
|
+
// password SHOULD be HashedPassword
|
|
86
|
+
const password: HashedPassword = user.password
|
|
87
|
+
expect(typeof password.compare).toBe('function')
|
|
88
|
+
|
|
89
|
+
// If the bug existed, this would be a type error because
|
|
90
|
+
// string | HashedPassword doesn't have .compare()
|
|
91
|
+
const canCompare: boolean = await password.compare('test')
|
|
92
|
+
expect(typeof canCompare).toBe('boolean')
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should preserve types with included relationships', async () => {
|
|
97
|
+
const context = getContext(testConfig, mockPrismaClient, null)
|
|
98
|
+
|
|
99
|
+
const users = await context.db.user.findMany({
|
|
100
|
+
include: {
|
|
101
|
+
posts: true,
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
if (users.length > 0) {
|
|
106
|
+
const user = users[0]
|
|
107
|
+
|
|
108
|
+
// Regular fields should have their original types
|
|
109
|
+
const name: string = user.name
|
|
110
|
+
const email: string = user.email
|
|
111
|
+
const id: string = user.id
|
|
112
|
+
|
|
113
|
+
// Password should be HashedPassword
|
|
114
|
+
const password: HashedPassword = user.password
|
|
115
|
+
|
|
116
|
+
// Posts should be included and typed
|
|
117
|
+
if (user.posts && user.posts.length > 0) {
|
|
118
|
+
const post = user.posts[0]
|
|
119
|
+
const title: string = post.title
|
|
120
|
+
expect(typeof title).toBe('string')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
expect(typeof name).toBe('string')
|
|
124
|
+
expect(typeof email).toBe('string')
|
|
125
|
+
expect(typeof id).toBe('string')
|
|
126
|
+
expect(typeof password.compare).toBe('function')
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should verify TypeScript narrowing works correctly', async () => {
|
|
131
|
+
const context = getContext(testConfig, mockPrismaClient, null)
|
|
132
|
+
|
|
133
|
+
const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
134
|
+
|
|
135
|
+
if (user) {
|
|
136
|
+
// This is the key test: if all fields were string | HashedPassword,
|
|
137
|
+
// we couldn't assign to string without type assertion
|
|
138
|
+
|
|
139
|
+
// These should compile without type assertions
|
|
140
|
+
function expectString(val: string): void {
|
|
141
|
+
expect(typeof val).toBe('string')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function expectHashedPassword(val: HashedPassword): void {
|
|
145
|
+
expect(typeof val.compare).toBe('function')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// These calls prove the types are correctly narrowed
|
|
149
|
+
expectString(user.name)
|
|
150
|
+
expectString(user.email)
|
|
151
|
+
expectString(user.id)
|
|
152
|
+
expectHashedPassword(user.password)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { config, list, getContext } from '../src/index.js'
|
|
3
|
+
import { text, password } from '../src/fields/index.js'
|
|
4
|
+
import { PrismaClient } from '@prisma/client'
|
|
5
|
+
import type { HashedPassword } from '../src/utils/password.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Type tests for password field transformations
|
|
9
|
+
* These tests verify that TypeScript correctly infers HashedPassword type
|
|
10
|
+
*/
|
|
11
|
+
describe('Password Field Type Safety', () => {
|
|
12
|
+
// Mock Prisma client for type testing
|
|
13
|
+
const mockPrismaClient = {
|
|
14
|
+
user: {
|
|
15
|
+
findUnique: async () => ({
|
|
16
|
+
id: '1',
|
|
17
|
+
email: 'test@example.com',
|
|
18
|
+
password: '$2a$10$hashedpassword',
|
|
19
|
+
}),
|
|
20
|
+
findMany: async () => [
|
|
21
|
+
{
|
|
22
|
+
id: '1',
|
|
23
|
+
email: 'test@example.com',
|
|
24
|
+
password: '$2a$10$hashedpassword',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
create: async () => ({
|
|
28
|
+
id: '1',
|
|
29
|
+
email: 'test@example.com',
|
|
30
|
+
password: '$2a$10$hashedpassword',
|
|
31
|
+
}),
|
|
32
|
+
update: async () => ({
|
|
33
|
+
id: '1',
|
|
34
|
+
email: 'test@example.com',
|
|
35
|
+
password: '$2a$10$hashedpassword',
|
|
36
|
+
}),
|
|
37
|
+
delete: async () => ({
|
|
38
|
+
id: '1',
|
|
39
|
+
email: 'test@example.com',
|
|
40
|
+
password: '$2a$10$hashedpassword',
|
|
41
|
+
}),
|
|
42
|
+
count: async () => 1,
|
|
43
|
+
},
|
|
44
|
+
} as unknown as PrismaClient
|
|
45
|
+
|
|
46
|
+
const testConfig = config({
|
|
47
|
+
db: {
|
|
48
|
+
provider: 'sqlite',
|
|
49
|
+
url: 'file:./test.db',
|
|
50
|
+
},
|
|
51
|
+
lists: {
|
|
52
|
+
User: list({
|
|
53
|
+
fields: {
|
|
54
|
+
email: text({ validation: { isRequired: true } }),
|
|
55
|
+
password: password({ validation: { isRequired: true } }),
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should transform password field to HashedPassword type in findUnique', async () => {
|
|
62
|
+
const context = getContext(testConfig, mockPrismaClient, null)
|
|
63
|
+
|
|
64
|
+
const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
65
|
+
|
|
66
|
+
// Type check: TypeScript should know that user.password is HashedPassword
|
|
67
|
+
if (user) {
|
|
68
|
+
// This should compile - compare method should exist
|
|
69
|
+
const isValid: boolean = await user.password.compare('plaintext')
|
|
70
|
+
expect(typeof isValid).toBe('boolean')
|
|
71
|
+
|
|
72
|
+
// Runtime check: verify it's actually a HashedPassword instance
|
|
73
|
+
expect(user.password).toBeDefined()
|
|
74
|
+
expect(typeof user.password.compare).toBe('function')
|
|
75
|
+
expect(typeof user.password.toString).toBe('function')
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should transform password field to HashedPassword type in findMany', async () => {
|
|
80
|
+
const context = getContext(testConfig, mockPrismaClient, null)
|
|
81
|
+
|
|
82
|
+
const users = await context.db.user.findMany()
|
|
83
|
+
|
|
84
|
+
// Type check: TypeScript should know that users[0].password is HashedPassword
|
|
85
|
+
if (users.length > 0) {
|
|
86
|
+
const user = users[0]
|
|
87
|
+
|
|
88
|
+
// This should compile - compare method should exist
|
|
89
|
+
const isValid: boolean = await user.password.compare('plaintext')
|
|
90
|
+
expect(typeof isValid).toBe('boolean')
|
|
91
|
+
|
|
92
|
+
// Runtime check
|
|
93
|
+
expect(user.password).toBeDefined()
|
|
94
|
+
expect(typeof user.password.compare).toBe('function')
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should transform password field to HashedPassword type in create', async () => {
|
|
99
|
+
const context = getContext(testConfig, mockPrismaClient, null)
|
|
100
|
+
|
|
101
|
+
const user = await context.db.user.create({
|
|
102
|
+
data: {
|
|
103
|
+
email: 'new@example.com',
|
|
104
|
+
password: 'plaintext',
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Type check: TypeScript should know that user.password is HashedPassword
|
|
109
|
+
if (user) {
|
|
110
|
+
// This should compile
|
|
111
|
+
const isValid: boolean = await user.password.compare('plaintext')
|
|
112
|
+
expect(typeof isValid).toBe('boolean')
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should transform password field to HashedPassword type in update', async () => {
|
|
117
|
+
const context = getContext(testConfig, mockPrismaClient, null)
|
|
118
|
+
|
|
119
|
+
const user = await context.db.user.update({
|
|
120
|
+
where: { id: '1' },
|
|
121
|
+
data: {
|
|
122
|
+
password: 'newpassword',
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Type check: TypeScript should know that user.password is HashedPassword
|
|
127
|
+
if (user) {
|
|
128
|
+
// This should compile
|
|
129
|
+
const isValid: boolean = await user.password.compare('newpassword')
|
|
130
|
+
expect(typeof isValid).toBe('boolean')
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should allow checking the HashedPassword type explicitly', async () => {
|
|
135
|
+
const context = getContext(testConfig, mockPrismaClient, null)
|
|
136
|
+
|
|
137
|
+
const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
138
|
+
|
|
139
|
+
if (user) {
|
|
140
|
+
// Explicit type assertion to verify TypeScript inference
|
|
141
|
+
const passwordField: HashedPassword = user.password
|
|
142
|
+
|
|
143
|
+
expect(passwordField).toBeDefined()
|
|
144
|
+
expect(await passwordField.compare('test')).toBe(false)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
hashPassword,
|
|
4
|
+
comparePassword,
|
|
5
|
+
isHashedPassword,
|
|
6
|
+
HashedPassword,
|
|
7
|
+
} from '../src/utils/password.js'
|
|
8
|
+
|
|
9
|
+
describe('Password Utilities', () => {
|
|
10
|
+
describe('hashPassword', () => {
|
|
11
|
+
it('should hash a plaintext password', async () => {
|
|
12
|
+
const plain = 'mypassword123'
|
|
13
|
+
const hashed = await hashPassword(plain)
|
|
14
|
+
|
|
15
|
+
expect(hashed).toBeDefined()
|
|
16
|
+
expect(hashed).not.toBe(plain)
|
|
17
|
+
expect(hashed.length).toBeGreaterThan(50)
|
|
18
|
+
expect(hashed).toMatch(/^\$2[aby]\$\d{2}\$/)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should generate different hashes for same password', async () => {
|
|
22
|
+
const plain = 'mypassword123'
|
|
23
|
+
const hash1 = await hashPassword(plain)
|
|
24
|
+
const hash2 = await hashPassword(plain)
|
|
25
|
+
|
|
26
|
+
expect(hash1).not.toBe(hash2) // Different salts
|
|
27
|
+
expect(await comparePassword(plain, hash1)).toBe(true)
|
|
28
|
+
expect(await comparePassword(plain, hash2)).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should reject empty string', async () => {
|
|
32
|
+
await expect(hashPassword('')).rejects.toThrow('Password must be a non-empty string')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should reject non-string input', async () => {
|
|
36
|
+
// @ts-expect-error - Testing invalid input
|
|
37
|
+
await expect(hashPassword(null)).rejects.toThrow('Password must be a non-empty string')
|
|
38
|
+
// @ts-expect-error - Testing invalid input
|
|
39
|
+
await expect(hashPassword(undefined)).rejects.toThrow('Password must be a non-empty string')
|
|
40
|
+
// @ts-expect-error - Testing invalid input
|
|
41
|
+
await expect(hashPassword(123)).rejects.toThrow('Password must be a non-empty string')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should accept custom cost factor', async () => {
|
|
45
|
+
const plain = 'mypassword123'
|
|
46
|
+
const hashed = await hashPassword(plain, 4) // Low cost for testing
|
|
47
|
+
|
|
48
|
+
expect(hashed).toMatch(/^\$2[aby]\$04\$/) // Cost factor 04
|
|
49
|
+
expect(await comparePassword(plain, hashed)).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('comparePassword', () => {
|
|
54
|
+
it('should return true for matching passwords', async () => {
|
|
55
|
+
const plain = 'mypassword123'
|
|
56
|
+
const hashed = await hashPassword(plain)
|
|
57
|
+
|
|
58
|
+
const result = await comparePassword(plain, hashed)
|
|
59
|
+
expect(result).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should return false for non-matching passwords', async () => {
|
|
63
|
+
const plain1 = 'mypassword123'
|
|
64
|
+
const plain2 = 'wrongpassword'
|
|
65
|
+
const hashed = await hashPassword(plain1)
|
|
66
|
+
|
|
67
|
+
const result = await comparePassword(plain2, hashed)
|
|
68
|
+
expect(result).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should return false for empty strings', async () => {
|
|
72
|
+
const hashed = await hashPassword('test')
|
|
73
|
+
|
|
74
|
+
expect(await comparePassword('', hashed)).toBe(false)
|
|
75
|
+
expect(await comparePassword('test', '')).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should return false for non-string inputs', async () => {
|
|
79
|
+
const hashed = await hashPassword('test')
|
|
80
|
+
|
|
81
|
+
// @ts-expect-error - Testing invalid input
|
|
82
|
+
expect(await comparePassword(null, hashed)).toBe(false)
|
|
83
|
+
// @ts-expect-error - Testing invalid input
|
|
84
|
+
expect(await comparePassword('test', null)).toBe(false)
|
|
85
|
+
// @ts-expect-error - Testing invalid input
|
|
86
|
+
expect(await comparePassword(undefined, hashed)).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should return false for invalid hash format', async () => {
|
|
90
|
+
const result = await comparePassword('test', 'not-a-valid-hash')
|
|
91
|
+
expect(result).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should be case sensitive', async () => {
|
|
95
|
+
const plain = 'MyPassword123'
|
|
96
|
+
const hashed = await hashPassword(plain)
|
|
97
|
+
|
|
98
|
+
expect(await comparePassword('MyPassword123', hashed)).toBe(true)
|
|
99
|
+
expect(await comparePassword('mypassword123', hashed)).toBe(false)
|
|
100
|
+
expect(await comparePassword('MYPASSWORD123', hashed)).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('isHashedPassword', () => {
|
|
105
|
+
it('should return true for bcrypt hashes', async () => {
|
|
106
|
+
const hash = await hashPassword('test')
|
|
107
|
+
expect(isHashedPassword(hash)).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should return true for various bcrypt formats', () => {
|
|
111
|
+
const hash2a = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
|
|
112
|
+
const hash2b = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
|
|
113
|
+
const hash2y = '$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
|
|
114
|
+
|
|
115
|
+
expect(isHashedPassword(hash2a)).toBe(true)
|
|
116
|
+
expect(isHashedPassword(hash2b)).toBe(true)
|
|
117
|
+
expect(isHashedPassword(hash2y)).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should return false for plaintext passwords', () => {
|
|
121
|
+
expect(isHashedPassword('mypassword123')).toBe(false)
|
|
122
|
+
expect(isHashedPassword('$2a$')).toBe(false) // Too short
|
|
123
|
+
expect(isHashedPassword('$3a$10$...')).toBe(false) // Wrong version
|
|
124
|
+
expect(isHashedPassword('')).toBe(false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should return false for non-string inputs', () => {
|
|
128
|
+
// @ts-expect-error - Testing invalid input
|
|
129
|
+
expect(isHashedPassword(null)).toBe(false)
|
|
130
|
+
// @ts-expect-error - Testing invalid input
|
|
131
|
+
expect(isHashedPassword(undefined)).toBe(false)
|
|
132
|
+
// @ts-expect-error - Testing invalid input
|
|
133
|
+
expect(isHashedPassword(123)).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('HashedPassword class', () => {
|
|
138
|
+
it('should wrap a hash and provide compare method', async () => {
|
|
139
|
+
const plain = 'mypassword123'
|
|
140
|
+
const hash = await hashPassword(plain)
|
|
141
|
+
const wrapped = new HashedPassword(hash)
|
|
142
|
+
|
|
143
|
+
const result = await wrapped.compare(plain)
|
|
144
|
+
expect(result).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should return false for wrong password', async () => {
|
|
148
|
+
const hash = await hashPassword('correct')
|
|
149
|
+
const wrapped = new HashedPassword(hash)
|
|
150
|
+
|
|
151
|
+
const result = await wrapped.compare('wrong')
|
|
152
|
+
expect(result).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should convert to string correctly', async () => {
|
|
156
|
+
const hash = await hashPassword('test')
|
|
157
|
+
const wrapped = new HashedPassword(hash)
|
|
158
|
+
|
|
159
|
+
expect(wrapped.toString()).toBe(hash)
|
|
160
|
+
expect(String(wrapped)).toBe(hash)
|
|
161
|
+
expect(`Password: ${wrapped}`).toBe(`Password: ${hash}`)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should serialize to JSON correctly', async () => {
|
|
165
|
+
const hash = await hashPassword('test')
|
|
166
|
+
const wrapped = new HashedPassword(hash)
|
|
167
|
+
|
|
168
|
+
const json = JSON.stringify({ password: wrapped })
|
|
169
|
+
expect(json).toBe(JSON.stringify({ password: hash }))
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should work with valueOf', async () => {
|
|
173
|
+
const hash = await hashPassword('test')
|
|
174
|
+
const wrapped = new HashedPassword(hash)
|
|
175
|
+
|
|
176
|
+
expect(wrapped.valueOf()).toBe(hash)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should throw error for invalid input', () => {
|
|
180
|
+
// @ts-expect-error - Testing invalid input
|
|
181
|
+
expect(() => new HashedPassword(null)).toThrow(
|
|
182
|
+
'HashedPassword requires a non-empty hash string',
|
|
183
|
+
)
|
|
184
|
+
// @ts-expect-error - Testing invalid input
|
|
185
|
+
expect(() => new HashedPassword(undefined)).toThrow(
|
|
186
|
+
'HashedPassword requires a non-empty hash string',
|
|
187
|
+
)
|
|
188
|
+
// @ts-expect-error - Testing invalid input
|
|
189
|
+
expect(() => new HashedPassword('')).toThrow(
|
|
190
|
+
'HashedPassword requires a non-empty hash string',
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should work in comparisons and object operations', async () => {
|
|
195
|
+
const hash = await hashPassword('test')
|
|
196
|
+
const wrapped = new HashedPassword(hash)
|
|
197
|
+
|
|
198
|
+
// Should be usable as a string
|
|
199
|
+
expect(wrapped == hash).toBe(true)
|
|
200
|
+
expect(wrapped === hash).toBe(false) // Not strictly equal (different types)
|
|
201
|
+
|
|
202
|
+
// Should work in object spread
|
|
203
|
+
const obj = { password: wrapped }
|
|
204
|
+
expect(obj.password.toString()).toBe(hash)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('Integration: Full password lifecycle', () => {
|
|
209
|
+
it('should hash, store, retrieve, and verify password', async () => {
|
|
210
|
+
// 1. Hash password (would happen in create operation)
|
|
211
|
+
const plainPassword = 'userPassword123!'
|
|
212
|
+
const hashedPassword = await hashPassword(plainPassword)
|
|
213
|
+
|
|
214
|
+
// 2. Store in database (simulated)
|
|
215
|
+
const storedUser = {
|
|
216
|
+
id: '1',
|
|
217
|
+
email: 'test@example.com',
|
|
218
|
+
password: hashedPassword,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 3. Retrieve from database and wrap with HashedPassword
|
|
222
|
+
const retrievedUser = {
|
|
223
|
+
...storedUser,
|
|
224
|
+
password: new HashedPassword(storedUser.password),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 4. Verify password on login
|
|
228
|
+
const loginAttempt1 = await retrievedUser.password.compare(plainPassword)
|
|
229
|
+
expect(loginAttempt1).toBe(true)
|
|
230
|
+
|
|
231
|
+
const loginAttempt2 = await retrievedUser.password.compare('wrongPassword')
|
|
232
|
+
expect(loginAttempt2).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should not re-hash already hashed passwords', async () => {
|
|
236
|
+
const plain = 'password123'
|
|
237
|
+
const hash = await hashPassword(plain)
|
|
238
|
+
|
|
239
|
+
// If password is already hashed, don't hash again
|
|
240
|
+
if (isHashedPassword(hash)) {
|
|
241
|
+
expect(isHashedPassword(hash)).toBe(true)
|
|
242
|
+
// Would skip hashing in actual implementation
|
|
243
|
+
} else {
|
|
244
|
+
// Would hash in actual implementation
|
|
245
|
+
await hashPassword(plain)
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
})
|
package/tsconfig.json
ADDED