ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1import { describe, it, expect, beforeEach, afterEach } from "vitest";
2import {
3 encryptToken,
4 decryptToken,
5 generateEncryptionKey,
6 isEncryptionConfigured,
7} from "./encryption.utils";
8import { ApiError } from "../errors";
9
10// A valid 64-char hex key (32 bytes) for testing
11const TEST_KEY =
12 "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2";
13
14describe("Encryption Utils", () => {
15 let originalKey: string | undefined;
16
17 beforeEach(() => {
18 originalKey = process.env.TOKEN_ENCRYPTION_KEY;
19 });
20
21 afterEach(() => {
22 if (originalKey !== undefined) {
23 process.env.TOKEN_ENCRYPTION_KEY = originalKey;
24 } else {
25 delete process.env.TOKEN_ENCRYPTION_KEY;
26 }
27 });
28
29 describe("generateEncryptionKey", () => {
30 it("generates a 64-character hex string", () => {
31 const key = generateEncryptionKey();
32 expect(key).toHaveLength(64);
33 expect(key).toMatch(/^[0-9a-f]{64}$/);
34 });
35
36 it("generates unique keys each time", () => {
37 const key1 = generateEncryptionKey();
38 const key2 = generateEncryptionKey();
39 expect(key1).not.toBe(key2);
40 });
41 });
42
43 describe("encryptToken / decryptToken", () => {
44 beforeEach(() => {
45 process.env.TOKEN_ENCRYPTION_KEY = TEST_KEY;
46 });
47
48 it("encrypts and decrypts a string", () => {
49 const data = "sensitive-data";
50 const encrypted = encryptToken(data);
51 const decrypted = decryptToken<string>(encrypted);
52 expect(decrypted).toBe(data);
53 });
54
55 it("encrypts and decrypts an object", () => {
56 const data = { did: "did:plc:test123", handle: "test.bsky.social" };
57 const encrypted = encryptToken(data);
58 const decrypted = decryptToken<typeof data>(encrypted);
59 expect(decrypted).toEqual(data);
60 });
61
62 it("produces different ciphertext each time (random IV)", () => {
63 const data = "same-data";
64 const encrypted1 = encryptToken(data);
65 const encrypted2 = encryptToken(data);
66 expect(encrypted1).not.toBe(encrypted2);
67 });
68
69 it("encrypted output is valid JSON with iv, data, tag fields", () => {
70 const encrypted = encryptToken("test");
71 const payload = JSON.parse(encrypted);
72 expect(payload).toHaveProperty("iv");
73 expect(payload).toHaveProperty("data");
74 expect(payload).toHaveProperty("tag");
75 expect(typeof payload.iv).toBe("string");
76 expect(typeof payload.data).toBe("string");
77 expect(typeof payload.tag).toBe("string");
78 });
79
80 it("throws ApiError when decrypting tampered data", () => {
81 const encrypted = encryptToken("test");
82 const payload = JSON.parse(encrypted);
83 // Tamper with encrypted data
84 payload.data = "0000" + payload.data.slice(4);
85 const tampered = JSON.stringify(payload);
86
87 expect(() => decryptToken(tampered)).toThrow(ApiError);
88 });
89
90 it("throws ApiError when decrypting invalid JSON", () => {
91 expect(() => decryptToken("not-json")).toThrow(ApiError);
92 });
93
94 it("throws ApiError when decrypting with missing fields", () => {
95 expect(() => decryptToken(JSON.stringify({ iv: "aa" }))).toThrow(
96 ApiError,
97 );
98 });
99
100 it("handles null and undefined values", () => {
101 const encryptedNull = encryptToken(null);
102 expect(decryptToken(encryptedNull)).toBeNull();
103 });
104
105 it("handles arrays", () => {
106 const data = [1, 2, 3];
107 const encrypted = encryptToken(data);
108 expect(decryptToken<number[]>(encrypted)).toEqual(data);
109 });
110
111 it("handles empty string", () => {
112 const encrypted = encryptToken("");
113 expect(decryptToken<string>(encrypted)).toBe("");
114 });
115 });
116
117 describe("encryptToken / decryptToken - key errors", () => {
118 it("throws ApiError when TOKEN_ENCRYPTION_KEY is not set", () => {
119 delete process.env.TOKEN_ENCRYPTION_KEY;
120 expect(() => encryptToken("data")).toThrow(ApiError);
121 try {
122 encryptToken("data");
123 expect.fail("Should have thrown");
124 } catch (error) {
125 const apiError = error as ApiError;
126 expect(apiError.statusCode).toBe(500);
127 expect(apiError.details).toContain("Encryption key not configured");
128 }
129 });
130
131 it("throws ApiError when TOKEN_ENCRYPTION_KEY has wrong length", () => {
132 process.env.TOKEN_ENCRYPTION_KEY = "tooshort";
133 expect(() => encryptToken("data")).toThrow(ApiError);
134 try {
135 encryptToken("data");
136 expect.fail("Should have thrown");
137 } catch (error) {
138 const apiError = error as ApiError;
139 expect(apiError.statusCode).toBe(500);
140 expect(apiError.details).toContain("Invalid encryption key");
141 }
142 });
143 });
144
145 describe("isEncryptionConfigured", () => {
146 it("returns true when key is set", () => {
147 process.env.TOKEN_ENCRYPTION_KEY = TEST_KEY;
148 expect(isEncryptionConfigured()).toBe(true);
149 });
150
151 it("returns false when key is not set (non-production)", () => {
152 delete process.env.TOKEN_ENCRYPTION_KEY;
153 const originalNodeEnv = process.env.NODE_ENV;
154 process.env.NODE_ENV = "test";
155 expect(isEncryptionConfigured()).toBe(false);
156 process.env.NODE_ENV = originalNodeEnv;
157 });
158
159 it("throws ApiError when key is not set in production", () => {
160 delete process.env.TOKEN_ENCRYPTION_KEY;
161 const originalNodeEnv = process.env.NODE_ENV;
162 process.env.NODE_ENV = "production";
163 try {
164 expect(() => isEncryptionConfigured()).toThrow(ApiError);
165 expect(() => isEncryptionConfigured()).toThrow(
166 "Encryption key not configured in production",
167 );
168 } finally {
169 process.env.NODE_ENV = originalNodeEnv;
170 }
171 });
172 });
173});