odac 1.4.7 → 1.4.9
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/CHANGELOG.md +45 -0
- package/client/odac.js +1 -1
- package/docs/ai/README.md +3 -2
- package/docs/ai/skills/SKILL.md +3 -2
- package/docs/ai/skills/backend/authentication.md +12 -6
- package/docs/ai/skills/backend/database.md +183 -12
- package/docs/ai/skills/backend/ipc.md +71 -12
- package/docs/ai/skills/backend/migrations.md +23 -0
- package/docs/ai/skills/backend/odac-var.md +155 -0
- package/docs/ai/skills/backend/utilities.md +1 -1
- package/docs/ai/skills/frontend/forms.md +23 -1
- package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
- package/docs/backend/04-routing/09-websocket.md +22 -1
- package/docs/backend/08-database/05-write-behind-cache.md +230 -0
- package/docs/backend/08-database/06-read-through-cache.md +206 -0
- package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
- package/docs/backend/10-authentication/05-session-management.md +12 -3
- package/docs/backend/13-utilities/01-odac-var.md +13 -19
- package/docs/backend/13-utilities/02-ipc.md +117 -0
- package/docs/frontend/03-forms/01-form-handling.md +15 -2
- package/docs/index.json +1 -1
- package/package.json +1 -1
- package/src/Auth.js +17 -0
- package/src/Database/Migration.js +219 -3
- package/src/Database/ReadCache.js +174 -0
- package/src/Database/WriteBuffer.js +605 -0
- package/src/Database.js +95 -1
- package/src/Ipc.js +343 -81
- package/src/Odac.js +2 -1
- package/src/Storage.js +4 -2
- package/src/Validator.js +1 -1
- package/src/Var.js +1 -0
- package/src/WebSocket.js +80 -23
- package/test/Database/Migration/migrate_column.test.js +168 -0
- package/test/Database/ReadCache/crossTable.test.js +179 -0
- package/test/Database/ReadCache/get.test.js +128 -0
- package/test/Database/ReadCache/invalidate.test.js +103 -0
- package/test/Database/ReadCache/proxy.test.js +184 -0
- package/test/Database/WriteBuffer/_recoverFromCheckpoint.test.js +207 -0
- package/test/Database/WriteBuffer/buffer.test.js +143 -0
- package/test/Database/WriteBuffer/flush.test.js +192 -0
- package/test/Database/WriteBuffer/get.test.js +72 -0
- package/test/Database/WriteBuffer/increment.test.js +118 -0
- package/test/Database/WriteBuffer/update.test.js +178 -0
- package/test/Database/insert.test.js +98 -0
- package/test/Ipc/hset.test.js +59 -0
- package/test/Ipc/incrBy.test.js +65 -0
- package/test/Ipc/lock.test.js +62 -0
- package/test/Ipc/rpush.test.js +68 -0
- package/test/Ipc/sadd.test.js +68 -0
- package/test/WebSocket/Client/fragmentation.test.js +130 -0
- package/test/WebSocket/Client/limits.test.js +10 -4
- package/test/WebSocket/Client/readyState.test.js +154 -0
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +0 -55
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests Ipc hash operations: hset() and hgetall().
|
|
7
|
+
* Why: Validates that hash merge semantics work correctly for Write-Behind Cache
|
|
8
|
+
* update coalescing (last-write-wins per field).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let Ipc
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
jest.resetModules()
|
|
15
|
+
Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
|
|
16
|
+
|
|
17
|
+
Ipc = require('../../src/Ipc')
|
|
18
|
+
global.Odac = {Config: {}}
|
|
19
|
+
await Ipc.init()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await Ipc.close()
|
|
24
|
+
delete global.Odac
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('Ipc - hset()', () => {
|
|
28
|
+
it('should store and retrieve hash fields', async () => {
|
|
29
|
+
await Ipc.hset('hash:a', {title: 'Hello', views: 100})
|
|
30
|
+
const result = await Ipc.hgetall('hash:a')
|
|
31
|
+
expect(result).toEqual({title: 'Hello', views: 100})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should merge new fields into existing hash', async () => {
|
|
35
|
+
await Ipc.hset('hash:b', {title: 'First'})
|
|
36
|
+
await Ipc.hset('hash:b', {slug: 'first-post'})
|
|
37
|
+
const result = await Ipc.hgetall('hash:b')
|
|
38
|
+
expect(result).toEqual({title: 'First', slug: 'first-post'})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should overwrite existing fields (last-write-wins)', async () => {
|
|
42
|
+
await Ipc.hset('hash:c', {title: 'Old'})
|
|
43
|
+
await Ipc.hset('hash:c', {title: 'New'})
|
|
44
|
+
const result = await Ipc.hgetall('hash:c')
|
|
45
|
+
expect(result).toEqual({title: 'New'})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should return null for non-existent hash', async () => {
|
|
49
|
+
const result = await Ipc.hgetall('hash:nonexistent')
|
|
50
|
+
expect(result).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should be deletable via del()', async () => {
|
|
54
|
+
await Ipc.hset('hash:d', {title: 'Test'})
|
|
55
|
+
await Ipc.del('hash:d')
|
|
56
|
+
const result = await Ipc.hgetall('hash:d')
|
|
57
|
+
expect(result).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests Ipc.incrBy() atomic counter operation.
|
|
7
|
+
* Why: Validates that concurrent increment operations are atomic and return
|
|
8
|
+
* accurate accumulated values, essential for Write-Behind Cache counters.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let Ipc
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
jest.resetModules()
|
|
15
|
+
Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
|
|
16
|
+
|
|
17
|
+
Ipc = require('../../src/Ipc')
|
|
18
|
+
global.Odac = {Config: {}}
|
|
19
|
+
await Ipc.init()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await Ipc.close()
|
|
24
|
+
delete global.Odac
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('Ipc - incrBy()', () => {
|
|
28
|
+
it('should initialize and return delta on first call', async () => {
|
|
29
|
+
const result = await Ipc.incrBy('counter:a', 5)
|
|
30
|
+
expect(result).toBe(5)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should accumulate multiple increments', async () => {
|
|
34
|
+
await Ipc.incrBy('counter:b', 3)
|
|
35
|
+
await Ipc.incrBy('counter:b', 7)
|
|
36
|
+
const result = await Ipc.incrBy('counter:b', 2)
|
|
37
|
+
expect(result).toBe(12) // 3 + 7 + 2
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should handle negative deltas (decrement)', async () => {
|
|
41
|
+
await Ipc.incrBy('counter:c', 10)
|
|
42
|
+
const result = await Ipc.incrBy('counter:c', -3)
|
|
43
|
+
expect(result).toBe(7)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should not interfere with different keys', async () => {
|
|
47
|
+
await Ipc.incrBy('counter:x', 5)
|
|
48
|
+
await Ipc.incrBy('counter:y', 10)
|
|
49
|
+
|
|
50
|
+
expect(await Ipc.get('counter:x')).toBe(5)
|
|
51
|
+
expect(await Ipc.get('counter:y')).toBe(10)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should be readable via get()', async () => {
|
|
55
|
+
await Ipc.incrBy('counter:d', 42)
|
|
56
|
+
const result = await Ipc.get('counter:d')
|
|
57
|
+
expect(result).toBe(42)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should work with decrBy()', async () => {
|
|
61
|
+
await Ipc.incrBy('counter:e', 10)
|
|
62
|
+
const result = await Ipc.decrBy('counter:e', 4)
|
|
63
|
+
expect(result).toBe(6)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests Ipc distributed lock: lock() and unlock().
|
|
7
|
+
* Why: Validates that only one process can hold the flush lock at a time,
|
|
8
|
+
* preventing duplicate writes in horizontal scaling scenarios.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let Ipc
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
jest.resetModules()
|
|
15
|
+
Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
|
|
16
|
+
|
|
17
|
+
Ipc = require('../../src/Ipc')
|
|
18
|
+
global.Odac = {Config: {}}
|
|
19
|
+
await Ipc.init()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await Ipc.close()
|
|
24
|
+
delete global.Odac
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('Ipc - lock()', () => {
|
|
28
|
+
it('should acquire lock and return true', async () => {
|
|
29
|
+
const result = await Ipc.lock('lock:a', 5)
|
|
30
|
+
expect(result).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should reject second acquisition (mutex)', async () => {
|
|
34
|
+
await Ipc.lock('lock:b', 5)
|
|
35
|
+
const result = await Ipc.lock('lock:b', 5)
|
|
36
|
+
expect(result).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should allow re-acquisition after unlock', async () => {
|
|
40
|
+
await Ipc.lock('lock:c', 5)
|
|
41
|
+
await Ipc.unlock('lock:c')
|
|
42
|
+
const result = await Ipc.lock('lock:c', 5)
|
|
43
|
+
expect(result).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should auto-expire after TTL', async () => {
|
|
47
|
+
await Ipc.lock('lock:d', 1) // 1 second TTL
|
|
48
|
+
|
|
49
|
+
// Wait for expiry
|
|
50
|
+
await new Promise(r => setTimeout(r, 1100))
|
|
51
|
+
|
|
52
|
+
// Lock should be available again
|
|
53
|
+
const result = await Ipc.lock('lock:d', 5)
|
|
54
|
+
expect(result).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should not interfere with different lock keys', async () => {
|
|
58
|
+
await Ipc.lock('lock:e', 5)
|
|
59
|
+
const result = await Ipc.lock('lock:f', 5)
|
|
60
|
+
expect(result).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests Ipc list operations: rpush() and lrange().
|
|
7
|
+
* Why: Validates that batch insert queue (Write-Behind Cache) correctly appends
|
|
8
|
+
* items and retrieves them in order.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let Ipc
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
jest.resetModules()
|
|
15
|
+
Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
|
|
16
|
+
|
|
17
|
+
Ipc = require('../../src/Ipc')
|
|
18
|
+
global.Odac = {Config: {}}
|
|
19
|
+
await Ipc.init()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await Ipc.close()
|
|
24
|
+
delete global.Odac
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('Ipc - rpush()', () => {
|
|
28
|
+
it('should append items and return new length', async () => {
|
|
29
|
+
const len1 = await Ipc.rpush('list:a', {action: 'view'})
|
|
30
|
+
const len2 = await Ipc.rpush('list:a', {action: 'click'})
|
|
31
|
+
expect(len1).toBe(1)
|
|
32
|
+
expect(len2).toBe(2)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should append multiple items at once', async () => {
|
|
36
|
+
const len = await Ipc.rpush('list:b', {a: 1}, {a: 2}, {a: 3})
|
|
37
|
+
expect(len).toBe(3)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('Ipc - lrange()', () => {
|
|
42
|
+
it('should return all items with 0, -1', async () => {
|
|
43
|
+
await Ipc.rpush('list:c', 'first')
|
|
44
|
+
await Ipc.rpush('list:c', 'second')
|
|
45
|
+
await Ipc.rpush('list:c', 'third')
|
|
46
|
+
|
|
47
|
+
const result = await Ipc.lrange('list:c', 0, -1)
|
|
48
|
+
expect(result).toEqual(['first', 'second', 'third'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should return a range of items', async () => {
|
|
52
|
+
await Ipc.rpush('list:d', 'a', 'b', 'c', 'd')
|
|
53
|
+
const result = await Ipc.lrange('list:d', 1, 2)
|
|
54
|
+
expect(result).toEqual(['b', 'c'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should return empty array for non-existent list', async () => {
|
|
58
|
+
const result = await Ipc.lrange('list:nonexistent', 0, -1)
|
|
59
|
+
expect(result).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should clear list on del()', async () => {
|
|
63
|
+
await Ipc.rpush('list:e', 'item')
|
|
64
|
+
await Ipc.del('list:e')
|
|
65
|
+
const result = await Ipc.lrange('list:e', 0, -1)
|
|
66
|
+
expect(result).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests Ipc set operations: sadd(), smembers(), srem().
|
|
7
|
+
* Why: WriteBuffer uses index sets to track active counter/update/queue keys
|
|
8
|
+
* for efficient flush discovery without expensive SCAN operations.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let Ipc
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
jest.resetModules()
|
|
15
|
+
Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
|
|
16
|
+
|
|
17
|
+
Ipc = require('../../src/Ipc')
|
|
18
|
+
global.Odac = {Config: {}}
|
|
19
|
+
await Ipc.init()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await Ipc.close()
|
|
24
|
+
delete global.Odac
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('Ipc - sadd()', () => {
|
|
28
|
+
it('should add members and return count of new additions', async () => {
|
|
29
|
+
const added = await Ipc.sadd('set:a', 'x', 'y', 'z')
|
|
30
|
+
expect(added).toBe(3)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should not duplicate existing members', async () => {
|
|
34
|
+
await Ipc.sadd('set:b', 'x', 'y')
|
|
35
|
+
const added = await Ipc.sadd('set:b', 'y', 'z')
|
|
36
|
+
expect(added).toBe(1) // Only 'z' is new
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('Ipc - smembers()', () => {
|
|
41
|
+
it('should return all members', async () => {
|
|
42
|
+
await Ipc.sadd('set:c', 'a', 'b', 'c')
|
|
43
|
+
const members = await Ipc.smembers('set:c')
|
|
44
|
+
expect(members.sort()).toEqual(['a', 'b', 'c'])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should return empty array for non-existent set', async () => {
|
|
48
|
+
const result = await Ipc.smembers('set:nonexistent')
|
|
49
|
+
expect(result).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('Ipc - srem()', () => {
|
|
54
|
+
it('should remove specified members', async () => {
|
|
55
|
+
await Ipc.sadd('set:d', 'a', 'b', 'c')
|
|
56
|
+
const removed = await Ipc.srem('set:d', 'b')
|
|
57
|
+
expect(removed).toBe(1)
|
|
58
|
+
|
|
59
|
+
const remaining = await Ipc.smembers('set:d')
|
|
60
|
+
expect(remaining.sort()).toEqual(['a', 'c'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should return 0 for non-existent members', async () => {
|
|
64
|
+
await Ipc.sadd('set:e', 'x')
|
|
65
|
+
const removed = await Ipc.srem('set:e', 'nonexistent')
|
|
66
|
+
expect(removed).toBe(0)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const {WebSocketServer, WebSocketClient} = require('../../../src/WebSocket.js')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds a masked WebSocket frame from raw payload bytes.
|
|
5
|
+
* Uses a zero mask key for deterministic test output.
|
|
6
|
+
*/
|
|
7
|
+
function buildFrame(opcode, payload, fin = true) {
|
|
8
|
+
const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
|
|
9
|
+
const maskKey = Buffer.alloc(4) // zero mask — XOR is identity
|
|
10
|
+
const masked = Buffer.from(buf)
|
|
11
|
+
|
|
12
|
+
const finBit = fin ? 0x80 : 0x00
|
|
13
|
+
const header = Buffer.alloc(2 + 4 + buf.length)
|
|
14
|
+
header[0] = finBit | opcode
|
|
15
|
+
header[1] = 0x80 | buf.length // masked bit + length
|
|
16
|
+
maskKey.copy(header, 2)
|
|
17
|
+
masked.copy(header, 6)
|
|
18
|
+
|
|
19
|
+
return header
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createMockSocket() {
|
|
23
|
+
return {
|
|
24
|
+
pause: jest.fn(),
|
|
25
|
+
resume: jest.fn(),
|
|
26
|
+
on: jest.fn(),
|
|
27
|
+
write: jest.fn(),
|
|
28
|
+
end: jest.fn(),
|
|
29
|
+
removeAllListeners: jest.fn(),
|
|
30
|
+
writable: true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('WebSocketClient Fragmentation', () => {
|
|
35
|
+
let server
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
server = new WebSocketServer()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should reassemble fragmented text messages', () => {
|
|
42
|
+
const socket = createMockSocket()
|
|
43
|
+
const client = new WebSocketClient(socket, server, 'frag-1')
|
|
44
|
+
client.resume()
|
|
45
|
+
|
|
46
|
+
const messages = []
|
|
47
|
+
client.on('message', msg => messages.push(msg))
|
|
48
|
+
|
|
49
|
+
const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
|
|
50
|
+
|
|
51
|
+
// Fragment 1: TEXT opcode, fin=false
|
|
52
|
+
dataHandler(buildFrame(0x1, 'hel', false))
|
|
53
|
+
// Fragment 2: CONTINUATION opcode, fin=false
|
|
54
|
+
dataHandler(buildFrame(0x0, 'lo ', false))
|
|
55
|
+
// Fragment 3: CONTINUATION opcode, fin=true
|
|
56
|
+
dataHandler(buildFrame(0x0, 'world', true))
|
|
57
|
+
|
|
58
|
+
expect(messages).toEqual(['hello world'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should reassemble fragmented binary messages', () => {
|
|
62
|
+
const socket = createMockSocket()
|
|
63
|
+
const client = new WebSocketClient(socket, server, 'frag-2')
|
|
64
|
+
client.resume()
|
|
65
|
+
|
|
66
|
+
const messages = []
|
|
67
|
+
client.on('message', msg => messages.push(msg))
|
|
68
|
+
|
|
69
|
+
const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
|
|
70
|
+
|
|
71
|
+
const part1 = Buffer.from([0x01, 0x02])
|
|
72
|
+
const part2 = Buffer.from([0x03, 0x04])
|
|
73
|
+
|
|
74
|
+
// Fragment 1: BINARY opcode, fin=false
|
|
75
|
+
dataHandler(buildFrame(0x2, part1, false))
|
|
76
|
+
// Fragment 2: CONTINUATION opcode, fin=true
|
|
77
|
+
dataHandler(buildFrame(0x0, part2, true))
|
|
78
|
+
|
|
79
|
+
expect(messages.length).toBe(1)
|
|
80
|
+
expect(Buffer.isBuffer(messages[0])).toBe(true)
|
|
81
|
+
expect(messages[0]).toEqual(Buffer.from([0x01, 0x02, 0x03, 0x04]))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should close with 1002 on unexpected continuation frame', () => {
|
|
85
|
+
const socket = createMockSocket()
|
|
86
|
+
const client = new WebSocketClient(socket, server, 'frag-3')
|
|
87
|
+
client.resume()
|
|
88
|
+
|
|
89
|
+
const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
|
|
90
|
+
|
|
91
|
+
// Send CONTINUATION without a preceding TEXT/BINARY
|
|
92
|
+
dataHandler(buildFrame(0x0, 'orphan', true))
|
|
93
|
+
|
|
94
|
+
expect(socket.end).toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should handle single unfragmented message normally', () => {
|
|
98
|
+
const socket = createMockSocket()
|
|
99
|
+
const client = new WebSocketClient(socket, server, 'frag-4')
|
|
100
|
+
client.resume()
|
|
101
|
+
|
|
102
|
+
const messages = []
|
|
103
|
+
client.on('message', msg => messages.push(msg))
|
|
104
|
+
|
|
105
|
+
const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
|
|
106
|
+
|
|
107
|
+
// Single complete frame: TEXT, fin=true
|
|
108
|
+
dataHandler(buildFrame(0x1, 'complete', true))
|
|
109
|
+
|
|
110
|
+
expect(messages).toEqual(['complete'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should discard fragment buffer on close', () => {
|
|
114
|
+
const socket = createMockSocket()
|
|
115
|
+
const client = new WebSocketClient(socket, server, 'frag-5')
|
|
116
|
+
client.resume()
|
|
117
|
+
|
|
118
|
+
const messages = []
|
|
119
|
+
client.on('message', msg => messages.push(msg))
|
|
120
|
+
|
|
121
|
+
const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
|
|
122
|
+
|
|
123
|
+
// Start a fragmented message but close before completion
|
|
124
|
+
dataHandler(buildFrame(0x1, 'partial', false))
|
|
125
|
+
client.close()
|
|
126
|
+
|
|
127
|
+
// No message should have been emitted
|
|
128
|
+
expect(messages).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -11,12 +11,15 @@ describe('WebSocketClient Limits', () => {
|
|
|
11
11
|
it('should close connection if payload exceeds limit', () => {
|
|
12
12
|
const socket = {
|
|
13
13
|
pause: jest.fn(),
|
|
14
|
+
resume: jest.fn(),
|
|
14
15
|
on: jest.fn(),
|
|
15
16
|
write: jest.fn(),
|
|
16
17
|
end: jest.fn(),
|
|
17
|
-
removeAllListeners: jest.fn()
|
|
18
|
+
removeAllListeners: jest.fn(),
|
|
19
|
+
writable: true
|
|
18
20
|
}
|
|
19
|
-
new WebSocketClient(socket, server, 'test-id', {maxPayload: 10})
|
|
21
|
+
const client = new WebSocketClient(socket, server, 'test-id', {maxPayload: 10})
|
|
22
|
+
client.resume()
|
|
20
23
|
|
|
21
24
|
const buffer = Buffer.alloc(100)
|
|
22
25
|
buffer[0] = 0x81
|
|
@@ -32,12 +35,15 @@ describe('WebSocketClient Limits', () => {
|
|
|
32
35
|
it('should close connection if rate limit exceeded', () => {
|
|
33
36
|
const socket = {
|
|
34
37
|
pause: jest.fn(),
|
|
38
|
+
resume: jest.fn(),
|
|
35
39
|
on: jest.fn(),
|
|
36
40
|
write: jest.fn(),
|
|
37
41
|
end: jest.fn(),
|
|
38
|
-
removeAllListeners: jest.fn()
|
|
42
|
+
removeAllListeners: jest.fn(),
|
|
43
|
+
writable: true
|
|
39
44
|
}
|
|
40
|
-
new WebSocketClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
|
|
45
|
+
const client = new WebSocketClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
|
|
46
|
+
client.resume()
|
|
41
47
|
|
|
42
48
|
const buffer = Buffer.alloc(7)
|
|
43
49
|
buffer[0] = 0x81
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const {WebSocketServer, WebSocketClient, READY_STATE} = require('../../../src/WebSocket.js')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Helper: creates a mock TCP socket with the minimum interface
|
|
5
|
+
* required by WebSocketClient.
|
|
6
|
+
*/
|
|
7
|
+
function createMockSocket() {
|
|
8
|
+
return {
|
|
9
|
+
pause: jest.fn(),
|
|
10
|
+
resume: jest.fn(),
|
|
11
|
+
on: jest.fn(),
|
|
12
|
+
write: jest.fn(),
|
|
13
|
+
end: jest.fn(),
|
|
14
|
+
removeAllListeners: jest.fn(),
|
|
15
|
+
writable: true
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('WebSocketClient readyState', () => {
|
|
20
|
+
let server
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
server = new WebSocketServer()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should expose static state constants matching READY_STATE enum', () => {
|
|
27
|
+
expect(WebSocketClient.CONNECTING).toBe(0)
|
|
28
|
+
expect(WebSocketClient.OPEN).toBe(1)
|
|
29
|
+
expect(WebSocketClient.CLOSING).toBe(2)
|
|
30
|
+
expect(WebSocketClient.CLOSED).toBe(3)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should export READY_STATE enum', () => {
|
|
34
|
+
expect(READY_STATE).toEqual({
|
|
35
|
+
CONNECTING: 0,
|
|
36
|
+
OPEN: 1,
|
|
37
|
+
CLOSING: 2,
|
|
38
|
+
CLOSED: 3
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should start in CONNECTING state', () => {
|
|
43
|
+
const socket = createMockSocket()
|
|
44
|
+
const client = new WebSocketClient(socket, server, 'rs-1')
|
|
45
|
+
expect(client.readyState).toBe(READY_STATE.CONNECTING)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should transition to OPEN on resume()', () => {
|
|
49
|
+
const socket = createMockSocket()
|
|
50
|
+
const client = new WebSocketClient(socket, server, 'rs-2')
|
|
51
|
+
|
|
52
|
+
client.resume()
|
|
53
|
+
|
|
54
|
+
expect(client.readyState).toBe(READY_STATE.OPEN)
|
|
55
|
+
expect(socket.resume).toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should transition to CLOSED after close()', () => {
|
|
59
|
+
const socket = createMockSocket()
|
|
60
|
+
const client = new WebSocketClient(socket, server, 'rs-3')
|
|
61
|
+
client.resume()
|
|
62
|
+
|
|
63
|
+
client.close()
|
|
64
|
+
|
|
65
|
+
expect(client.readyState).toBe(READY_STATE.CLOSED)
|
|
66
|
+
expect(socket.end).toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should be idempotent — second close() is a no-op', () => {
|
|
70
|
+
const socket = createMockSocket()
|
|
71
|
+
const client = new WebSocketClient(socket, server, 'rs-4')
|
|
72
|
+
client.resume()
|
|
73
|
+
|
|
74
|
+
client.close()
|
|
75
|
+
client.close()
|
|
76
|
+
|
|
77
|
+
expect(socket.end).toHaveBeenCalledTimes(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should not send data when in CONNECTING state', () => {
|
|
81
|
+
const socket = createMockSocket()
|
|
82
|
+
const client = new WebSocketClient(socket, server, 'rs-5')
|
|
83
|
+
|
|
84
|
+
client.send('hello')
|
|
85
|
+
|
|
86
|
+
expect(socket.write).not.toHaveBeenCalled()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should not send data when in CLOSED state', () => {
|
|
90
|
+
const socket = createMockSocket()
|
|
91
|
+
const client = new WebSocketClient(socket, server, 'rs-6')
|
|
92
|
+
client.resume()
|
|
93
|
+
client.close()
|
|
94
|
+
|
|
95
|
+
client.send('hello')
|
|
96
|
+
|
|
97
|
+
// Only the close frame write should exist, no data frame
|
|
98
|
+
const writes = socket.write.mock.calls
|
|
99
|
+
const lastWrite = writes[writes.length - 1]
|
|
100
|
+
// Close frame starts with 0x88
|
|
101
|
+
expect(lastWrite[0][0]).toBe(0x88)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should not send ping when not OPEN', () => {
|
|
105
|
+
const socket = createMockSocket()
|
|
106
|
+
const client = new WebSocketClient(socket, server, 'rs-7')
|
|
107
|
+
|
|
108
|
+
client.ping()
|
|
109
|
+
|
|
110
|
+
expect(socket.write).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should not write when socket is not writable', () => {
|
|
114
|
+
const socket = createMockSocket()
|
|
115
|
+
socket.writable = false
|
|
116
|
+
const client = new WebSocketClient(socket, server, 'rs-8')
|
|
117
|
+
client.resume()
|
|
118
|
+
|
|
119
|
+
client.send('hello')
|
|
120
|
+
|
|
121
|
+
expect(socket.write).not.toHaveBeenCalled()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should transition to CLOSED when socket fires close event', () => {
|
|
125
|
+
const socket = createMockSocket()
|
|
126
|
+
const client = new WebSocketClient(socket, server, 'rs-9')
|
|
127
|
+
server.clients.set('rs-9', client)
|
|
128
|
+
client.resume()
|
|
129
|
+
|
|
130
|
+
// Simulate socket 'close' event
|
|
131
|
+
const closeHandler = socket.on.mock.calls.find(c => c[0] === 'close')[1]
|
|
132
|
+
closeHandler()
|
|
133
|
+
|
|
134
|
+
expect(client.readyState).toBe(READY_STATE.CLOSED)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should emit close event only once on double cleanup', () => {
|
|
138
|
+
const socket = createMockSocket()
|
|
139
|
+
const client = new WebSocketClient(socket, server, 'rs-10')
|
|
140
|
+
server.clients.set('rs-10', client)
|
|
141
|
+
client.resume()
|
|
142
|
+
|
|
143
|
+
const closeSpy = jest.fn()
|
|
144
|
+
client.on('close', closeSpy)
|
|
145
|
+
|
|
146
|
+
client.close()
|
|
147
|
+
|
|
148
|
+
// Simulate socket 'close' event firing after close() already cleaned up
|
|
149
|
+
const closeHandler = socket.on.mock.calls.find(c => c[0] === 'close')
|
|
150
|
+
if (closeHandler) closeHandler[1]()
|
|
151
|
+
|
|
152
|
+
expect(closeSpy).toHaveBeenCalledTimes(1)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
## 🔐 User Logins with `Auth.js`
|
|
2
|
-
|
|
3
|
-
The `Odac.Auth` service is your bouncer, managing who gets in and who stays out. It handles user login sessions for you.
|
|
4
|
-
|
|
5
|
-
#### Letting a User In
|
|
6
|
-
|
|
7
|
-
`Odac.Auth.login(userId, userData)`
|
|
8
|
-
|
|
9
|
-
* `userId`: A unique ID for the user (like their database ID).
|
|
10
|
-
* `userData`: An object with any user info you want to remember, like their username or role.
|
|
11
|
-
|
|
12
|
-
When you call this, `Auth` creates a secure session for the user.
|
|
13
|
-
|
|
14
|
-
> **💡 Enterprise Security:** ODAC automatically handles **Token Rotation** every 15 minutes (configurable) and includes built-in **CSRF protection** for all forms. Sessions are persistent across browser restarts by default.
|
|
15
|
-
|
|
16
|
-
#### Checking the Guest List
|
|
17
|
-
|
|
18
|
-
* `Odac.Auth.isLogin()`: Is the current user logged in? Returns `true` or `false`.
|
|
19
|
-
* `Odac.Auth.getId()`: Gets the ID of the logged-in user.
|
|
20
|
-
* `Odac.Auth.get('some-key')`: Grabs a specific piece of info from the `userData` you stored.
|
|
21
|
-
|
|
22
|
-
#### Showing a User Out
|
|
23
|
-
|
|
24
|
-
* `Odac.Auth.logout()`: Ends the user's session and logs them out.
|
|
25
|
-
|
|
26
|
-
#### Example: A Login Flow
|
|
27
|
-
```javascript
|
|
28
|
-
// Controller for your login form
|
|
29
|
-
module.exports = async function (Odac) {
|
|
30
|
-
const { username, password } = Odac.Request.post;
|
|
31
|
-
|
|
32
|
-
// IMPORTANT: You need to write your own code to find the user in your database!
|
|
33
|
-
const user = await yourDatabase.findUser(username, password);
|
|
34
|
-
|
|
35
|
-
if (user) {
|
|
36
|
-
// User is valid! Log them in.
|
|
37
|
-
Odac.Auth.login(user.id, { username: user.username });
|
|
38
|
-
return Odac.direct('/dashboard'); // Send them to their dashboard
|
|
39
|
-
} else {
|
|
40
|
-
// Bad credentials, send them back to the login page
|
|
41
|
-
return Odac.direct('/login?error=1');
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// A protected dashboard page
|
|
46
|
-
module.exports = function (Odac) {
|
|
47
|
-
// If they're not logged in, kick them back to the login page.
|
|
48
|
-
if (!Odac.Auth.isLogin()) {
|
|
49
|
-
return Odac.direct('/login');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const username = Odac.Auth.get('username');
|
|
53
|
-
return `Welcome back, ${username}!`;
|
|
54
|
-
}
|
|
55
|
-
```
|