test: add headless integration test suite for refreshToken() lifecycle
Some checks failed
Stuffle/iam-client-sdk/pipeline/pr-main There was a failure building this commit
Some checks failed
Stuffle/iam-client-sdk/pipeline/pr-main There was a failure building this commit
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist
|
||||
tests/.env.test
|
||||
835
package-lock.json
generated
835
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,9 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"react": "^18.2.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
|
||||
14
tests/.env.test.example
Normal file
14
tests/.env.test.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Copy this to tests/.env.test (gitignored) and fill in a real refresh token.
|
||||
#
|
||||
# How to get IAM_REFRESH_TOKEN:
|
||||
# 1. Open https://nebula.stuffle.io in a browser, log in.
|
||||
# 2. DevTools → Application → Local Storage → https://nebula.stuffle.io
|
||||
# 3. Copy value of key: stuffle_iam_refresh_token
|
||||
# 4. Paste below.
|
||||
#
|
||||
# Then run: IAM_REFRESH_TOKEN=<value> npm test
|
||||
# Or: cp tests/.env.test.example tests/.env.test (and edit)
|
||||
|
||||
IAM_ISSUER=https://iam.armco.dev
|
||||
IAM_CLIENT_ID=client_5bb63e4ae1644c9d97ffc84a5d228c7c
|
||||
IAM_REFRESH_TOKEN=
|
||||
244
tests/refresh-token.integration.test.ts
Normal file
244
tests/refresh-token.integration.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* IAM Client SDK — refreshToken() integration tests
|
||||
*
|
||||
* These tests run against the REAL IAM server (iam.armco.dev).
|
||||
* They require a valid refresh token obtained from a prior login session.
|
||||
*
|
||||
* ## How to get a refresh token for the first run:
|
||||
*
|
||||
* 1. Open Nebula in browser, log in normally.
|
||||
* 2. Open DevTools → Application → Local Storage → https://nebula.stuffle.io
|
||||
* 3. Copy the value of "stuffle_iam_refresh_token"
|
||||
* 4. Create tests/.env.test (gitignored) with:
|
||||
*
|
||||
* IAM_REFRESH_TOKEN=<paste here>
|
||||
*
|
||||
* Or set the env var directly:
|
||||
* IAM_REFRESH_TOKEN=<value> npm test
|
||||
*
|
||||
* ## What is tested:
|
||||
* T1. refreshToken() succeeds with a valid refresh token → returns new tokens
|
||||
* T2. refreshToken() stores new tokens in localStorage
|
||||
* T3. getAccessToken() with missing access token triggers refreshToken()
|
||||
* T4. getAccessToken() with expired access token triggers refreshToken()
|
||||
* T5. refreshToken() with invalid/expired refresh token clears storage + notifies listeners
|
||||
* T6. setupAutoRefresh via init() schedules next refresh when token is valid
|
||||
* T7. simulateExpiry flow: remove access token → getAccessToken() → refresh → isAuthenticated()
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
||||
import { createStuffleIAMClient } from '../src/index'
|
||||
import type { StuffleIAMClient } from '../src/client'
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const IAM_ISSUER = process.env.IAM_ISSUER ?? 'https://iam.armco.dev'
|
||||
const IAM_CLIENT_ID = process.env.IAM_CLIENT_ID ?? 'client_5bb63e4ae1644c9d97ffc84a5d228c7c'
|
||||
const INITIAL_REFRESH_TOKEN = process.env.IAM_REFRESH_TOKEN ?? ''
|
||||
|
||||
const SKIP_IF_NO_TOKEN = !INITIAL_REFRESH_TOKEN
|
||||
? 'IAM_REFRESH_TOKEN env var not set — skipping integration tests'
|
||||
: false
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeClient(): StuffleIAMClient {
|
||||
return createStuffleIAMClient({
|
||||
issuer: IAM_ISSUER,
|
||||
clientId: IAM_CLIENT_ID,
|
||||
redirectUri: 'https://nebula.stuffle.io/callback',
|
||||
scopes: ['openid', 'profile', 'email', 'offline_access'],
|
||||
storage: 'localStorage',
|
||||
autoRefresh: false, // disable background timers in tests
|
||||
}) as unknown as StuffleIAMClient
|
||||
}
|
||||
|
||||
/** Seed localStorage with a refresh token (and optionally a stale access token) */
|
||||
function seedStorage(refreshToken: string, accessToken?: string): void {
|
||||
localStorage.setItem('stuffle_iam_refresh_token', refreshToken)
|
||||
if (accessToken) {
|
||||
localStorage.setItem('stuffle_iam_access_token', accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
function clearStorage(): void {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k?.startsWith('stuffle_iam_')) keys.push(k!)
|
||||
}
|
||||
keys.forEach(k => localStorage.removeItem(k))
|
||||
}
|
||||
|
||||
/** Build a JWT with a past exp claim (signature deliberately invalid — client doesn't verify) */
|
||||
function expiredJwt(): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
const payload = btoa(JSON.stringify({
|
||||
sub: 'test-user',
|
||||
exp: Math.floor(Date.now() / 1000) - 3600,
|
||||
iat: Math.floor(Date.now() / 1000) - 7200,
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
return `${header}.${payload}.fakesig`
|
||||
}
|
||||
|
||||
// ── Pre-flight check ───────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(async () => {
|
||||
if (SKIP_IF_NO_TOKEN) {
|
||||
console.warn(`\n⚠️ ${SKIP_IF_NO_TOKEN}\n`)
|
||||
return
|
||||
}
|
||||
// Validate IAM reachability
|
||||
try {
|
||||
const res = await fetch(`${IAM_ISSUER}/.well-known/openid-configuration`)
|
||||
if (!res.ok) throw new Error(`Discovery endpoint returned ${res.status}`)
|
||||
console.log(`✅ IAM reachable at ${IAM_ISSUER}`)
|
||||
} catch (e) {
|
||||
console.error(`❌ IAM not reachable at ${IAM_ISSUER}:`, e)
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('refreshToken() — real IAM', () => {
|
||||
it.skipIf(!!SKIP_IF_NO_TOKEN)('T1: succeeds with valid refresh token and returns new tokens', async () => {
|
||||
const client = makeClient()
|
||||
seedStorage(INITIAL_REFRESH_TOKEN)
|
||||
|
||||
const result = await client.refreshToken()
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.access_token).toBeTruthy()
|
||||
expect(result!.refresh_token).toBeTruthy()
|
||||
expect(typeof result!.expires_in).toBe('number')
|
||||
expect(result!.expires_in).toBeGreaterThan(0)
|
||||
|
||||
console.log(` → new token expires_in: ${result!.expires_in}s`)
|
||||
})
|
||||
|
||||
it.skipIf(!!SKIP_IF_NO_TOKEN)('T2: stores new tokens in localStorage after refresh', async () => {
|
||||
const client = makeClient()
|
||||
seedStorage(INITIAL_REFRESH_TOKEN)
|
||||
|
||||
await client.refreshToken()
|
||||
|
||||
const storedAt = localStorage.getItem('stuffle_iam_access_token')
|
||||
const storedRt = localStorage.getItem('stuffle_iam_refresh_token')
|
||||
const storedExp = localStorage.getItem('stuffle_iam_expires_at')
|
||||
|
||||
expect(storedAt).toBeTruthy()
|
||||
expect(storedRt).toBeTruthy()
|
||||
expect(storedExp).toBeTruthy()
|
||||
expect(parseInt(storedExp!)).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
it.skipIf(!!SKIP_IF_NO_TOKEN)('T3: getAccessToken() with NO access token triggers refreshToken()', async () => {
|
||||
const client = makeClient()
|
||||
// Only seed refresh token — no access token
|
||||
seedStorage(INITIAL_REFRESH_TOKEN)
|
||||
|
||||
const token = await client.getAccessToken()
|
||||
|
||||
expect(token).toBeTruthy()
|
||||
// Access token should now be in storage
|
||||
expect(localStorage.getItem('stuffle_iam_access_token')).toBeTruthy()
|
||||
})
|
||||
|
||||
it.skipIf(!!SKIP_IF_NO_TOKEN)('T4: getAccessToken() with expired access token triggers refreshToken()', async () => {
|
||||
const client = makeClient()
|
||||
seedStorage(INITIAL_REFRESH_TOKEN, expiredJwt())
|
||||
|
||||
const token = await client.getAccessToken()
|
||||
|
||||
expect(token).toBeTruthy()
|
||||
// New token must differ from the fake expired one
|
||||
const stored = localStorage.getItem('stuffle_iam_access_token')
|
||||
expect(stored).not.toBe(expiredJwt())
|
||||
})
|
||||
|
||||
it.skipIf(!!SKIP_IF_NO_TOKEN)('T7: simulateExpiry flow — remove access token → getAccessToken() → still authenticated', async () => {
|
||||
const client = makeClient()
|
||||
|
||||
// Step 1: seed a valid session (simulates app on page load with fresh tokens)
|
||||
seedStorage(INITIAL_REFRESH_TOKEN)
|
||||
const firstRefresh = await client.refreshToken()
|
||||
expect(firstRefresh).not.toBeNull()
|
||||
|
||||
// Step 2: simulate the hourly expiry — remove just the access token
|
||||
localStorage.removeItem('stuffle_iam_access_token')
|
||||
localStorage.setItem('stuffle_iam_expires_at', (Date.now() - 1000).toString())
|
||||
|
||||
expect(client.isAuthenticated()).toBe(false) // confirms access token gone
|
||||
|
||||
// Step 3: call getAccessToken() — should transparently refresh
|
||||
const newToken = await client.getAccessToken()
|
||||
|
||||
expect(newToken).toBeTruthy()
|
||||
expect(client.isAuthenticated()).toBe(true)
|
||||
console.log(' → isAuthenticated() = true after simulated expiry + refresh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshToken() — failure paths (no real IAM needed)', () => {
|
||||
it('T5a: returns null when no refresh token in storage', async () => {
|
||||
const client = makeClient()
|
||||
clearStorage() // nothing seeded
|
||||
|
||||
const result = await client.refreshToken()
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('T5b: clears storage and notifies listeners on server rejection (bad refresh token)', async () => {
|
||||
const client = makeClient()
|
||||
seedStorage('definitely_invalid_refresh_token_xyz')
|
||||
|
||||
const notifications: boolean[] = []
|
||||
client.subscribe((state) => notifications.push(state.isAuthenticated))
|
||||
|
||||
const result = await client.refreshToken()
|
||||
|
||||
expect(result).toBeNull()
|
||||
// Storage must be cleared
|
||||
expect(localStorage.getItem('stuffle_iam_access_token')).toBeNull()
|
||||
expect(localStorage.getItem('stuffle_iam_refresh_token')).toBeNull()
|
||||
// Listeners must be notified with isAuthenticated=false
|
||||
expect(notifications.length).toBeGreaterThanOrEqual(1)
|
||||
expect(notifications[notifications.length - 1]).toBe(false)
|
||||
})
|
||||
|
||||
it('T5c: getAccessToken() returns null when both tokens absent', async () => {
|
||||
const client = makeClient()
|
||||
clearStorage()
|
||||
|
||||
const token = await client.getAccessToken()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
|
||||
it('T6: init() with valid (non-expired) access token returns true without refresh', async () => {
|
||||
// Build a JWT with exp 1 hour in the future
|
||||
const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
const payload = btoa(JSON.stringify({
|
||||
sub: 'test-user',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
const fakeValidJwt = `${header}.${payload}.fakesig`
|
||||
|
||||
const client = makeClient()
|
||||
localStorage.setItem('stuffle_iam_access_token', fakeValidJwt)
|
||||
localStorage.setItem('stuffle_iam_refresh_token', 'some_refresh_token')
|
||||
localStorage.setItem('stuffle_iam_expires_at', (Date.now() + 3_600_000).toString())
|
||||
|
||||
const refreshSpy = vi.spyOn(client, 'refreshToken')
|
||||
|
||||
const authenticated = await client.init()
|
||||
|
||||
expect(authenticated).toBe(true)
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
27
tests/setup.ts
Normal file
27
tests/setup.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Vitest global setup for iam-client-sdk tests.
|
||||
*
|
||||
* jsdom provides localStorage / sessionStorage.
|
||||
* Node 18+ provides globalThis.fetch and globalThis.crypto.
|
||||
* We wire them into the jsdom window so the SDK can find them.
|
||||
*/
|
||||
|
||||
import { beforeEach } from 'vitest'
|
||||
|
||||
// Node 18+ crypto → make it available as window.crypto
|
||||
if (typeof window !== 'undefined' && !window.crypto) {
|
||||
Object.defineProperty(window, 'crypto', {
|
||||
value: globalThis.crypto,
|
||||
writable: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Clear all stuffle_iam_ localStorage keys before each test
|
||||
beforeEach(() => {
|
||||
const keysToRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k?.startsWith('stuffle_iam_')) keysToRemove.push(k)
|
||||
}
|
||||
keysToRemove.forEach(k => localStorage.removeItem(k))
|
||||
})
|
||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { resolve } from 'path'
|
||||
|
||||
dotenvConfig({ path: resolve(__dirname, 'tests/.env.test'), override: false })
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user