ReviewOS

also looking at this

loghqorg/loghq

A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials

#15
Merged glennmichael123 wants to merge feat/read-scoped-project-key into main
9 files +537 -17
tests/unit/read-key.test.tsadded+120-0
Changes to tests/unit/read-key.test.ts
@@ -0,0 +1,120 @@
1import { describe, expect, test } from 'bun:test'
2import { authorizeRead, LAST_USED_RESOLUTION_MS, looksLikeReadKey, newReadKey, sameKey, shouldRecordUse } from '../../app/Support/read-key'
3
4const KEY = newReadKey()
5const project = { id: 'demo', read_key: KEY, is_active: true }
6
7describe('newReadKey', () => {
8 test('is prefixed and carries 256 bits of hex', () => {
9 expect(KEY.startsWith('lhq_read_')).toBe(true)
10 expect(KEY.slice('lhq_read_'.length)).toMatch(/^[0-9a-f]{64}$/)
11 })
12
13 test('does not repeat', () => {
14 const keys = new Set(Array.from({ length: 200 }, () => newReadKey()))
15 expect(keys.size).toBe(200)
16 })
17})
18
19describe('looksLikeReadKey', () => {
20 test('accepts a key it minted', () => {
21 expect(looksLikeReadKey(KEY)).toBe(true)
22 })
23
24 test('rejects anything else a bearer header might carry', () => {
25 // The prefix is what keeps a read key out of Auth.getUserFromToken, so a
26 // user's token and an ingest key must never pass for one.
27 for (const value of [
28 '',
29 null,
30 undefined,
31 'loghq_5f3a19b7c4d2e8091ab12cd34ef56ab7',
32 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig',
33 `${'lhq_read_'}${'z'.repeat(64)}`,
34 `${'lhq_read_'}${'a'.repeat(63)}`,
35 `${'lhq_read_'}${'a'.repeat(65)}`,
36 KEY.toUpperCase(),
37 ])
38 expect(looksLikeReadKey(value)).toBe(false)
39 })
40})
41
42describe('sameKey', () => {
43 test('matches an identical key and nothing else', () => {
44 expect(sameKey(KEY, KEY)).toBe(true)
45 expect(sameKey(KEY, newReadKey())).toBe(false)
46 })
47
48 test('is false on a shared prefix, not just a differing first byte', () => {
49 const other = `${KEY.slice(0, -1)}${KEY.endsWith('a') ? 'b' : 'a'}`
50 expect(sameKey(other, KEY)).toBe(false)
51 })
52
53 test('does not throw on a length mismatch', () => {
54 expect(sameKey('short', KEY)).toBe(false)
55 expect(sameKey(KEY, 'short')).toBe(false)
56 })
57})
58
59describe('authorizeRead', () => {
60 test('allows the project its own key', () => {
61 expect(authorizeRead(project, KEY)).toEqual({ ok: true })
62 })
63
64 test('refuses an unknown project with 404', () => {
65 expect(authorizeRead(null, KEY)).toEqual({ ok: false, status: 404, error: 'unknown project' })
66 expect(authorizeRead(undefined, KEY)).toEqual({ ok: false, status: 404, error: 'unknown project' })
67 })
68
69 test('refuses an inactive project with 403, however is_active is stored', () => {
70 // sqlite hands booleans back as 0/1.
71 expect(authorizeRead({ ...project, is_active: false }, KEY).ok).toBe(false)
72 expect(authorizeRead({ ...project, is_active: 0 }, KEY)).toEqual({ ok: false, status: 403, error: 'project inactive' })
73 })
74
75 test('refuses a project with no key issued, whatever is presented', () => {
76 // Guards `null === null`: no key configured must never authorise, not even
77 // against a caller presenting nothing.
78 for (const presented of [KEY, null, undefined, ''])
79 expect(authorizeRead({ ...project, read_key: null }, presented).ok).toBe(false)
80 expect(authorizeRead({ ...project, read_key: null }, KEY).status).toBe(403)
81 })
82
83 test('refuses another project\'s key with 401', () => {
84 expect(authorizeRead(project, newReadKey())).toEqual({ ok: false, status: 401, error: 'invalid read key' })
85 })
86
87 test('refuses an ingest key, which grants writes and must not grant reads', () => {
88 expect(authorizeRead({ ...project, read_key: KEY }, 'loghq_5f3a19b7c4d2e8091ab12cd34ef56ab7').ok).toBe(false)
89 })
90
91 test('refuses a stored value that is not a well-formed key', () => {
92 // A column hand-edited to something short must not become a weak key.
93 expect(authorizeRead({ ...project, read_key: 'x' }, 'x').ok).toBe(false)
94 })
95})
96
97describe('shouldRecordUse', () => {
98 const now = new Date('2026-10-19T12:00:00.000Z')
99
100 test('records the first use', () => {
101 expect(shouldRecordUse(null, now)).toBe(true)
102 expect(shouldRecordUse(undefined, now)).toBe(true)
103 })
104
105 test('records again once the resolution has passed, and not before', () => {
106 const justBefore = new Date(now.getTime() - LAST_USED_RESOLUTION_MS + 1).toISOString()
107 const atResolution = new Date(now.getTime() - LAST_USED_RESOLUTION_MS).toISOString()
108 expect(shouldRecordUse(justBefore, now)).toBe(false)
109 expect(shouldRecordUse(atResolution, now)).toBe(true)
110 })
111
112 test('records when the stored stamp cannot be read', () => {
113 // Better an extra write than a key that never looks used.
114 expect(shouldRecordUse('not a date', now)).toBe(true)
115 })
116
117 test('accepts a Date as well as a string', () => {
118 expect(shouldRecordUse(new Date(now.getTime() - 1000), now)).toBe(false)
119 })
120})