rastack 0.0.58 → 0.0.60

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +190 -0
  3. package/dist/deploy/ci.js +2 -2
  4. package/dist/deploy/io.js +2 -2
  5. package/dist/dev/harness.d.ts +1 -1
  6. package/dist/dev/harness.js +1 -2
  7. package/dist/external/cache.d.ts +28 -0
  8. package/dist/external/cache.js +35 -0
  9. package/dist/external/envelope.d.ts +35 -0
  10. package/dist/external/envelope.js +48 -0
  11. package/dist/external/graphql.d.ts +25 -0
  12. package/dist/external/graphql.js +78 -0
  13. package/dist/external/handler.d.ts +52 -0
  14. package/dist/external/handler.js +139 -0
  15. package/dist/external/index.d.ts +19 -0
  16. package/dist/external/index.js +38 -0
  17. package/dist/external/lambda.d.ts +49 -0
  18. package/dist/external/lambda.js +86 -0
  19. package/dist/external/rest.d.ts +17 -0
  20. package/dist/external/rest.js +46 -0
  21. package/dist/external/route.d.ts +12 -0
  22. package/dist/external/route.js +33 -0
  23. package/dist/external/service.d.ts +50 -0
  24. package/dist/external/service.js +59 -0
  25. package/dist/external/types.d.ts +107 -0
  26. package/dist/external/types.js +22 -0
  27. package/dist/external/util.d.ts +16 -0
  28. package/dist/external/util.js +58 -0
  29. package/dist/rastack-dev.js +16 -1
  30. package/dist/rastack-wasm-build.d.ts +1 -1
  31. package/dist/rastack-wasm-build.js +6 -6
  32. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  33. package/external.ts +10 -0
  34. package/hooks/query/delete.ts +12 -11
  35. package/hooks/query/fetch.ts +6 -8
  36. package/hooks/query/interfaces.ts +1 -4
  37. package/hooks/query/list.ts +16 -9
  38. package/hooks/query/update.ts +8 -6
  39. package/package.json +24 -2
  40. package/scripts/copy-templates.js +1 -1
  41. package/scripts/copy-wasm.js +1 -1
  42. package/scripts/dev-install.mjs +63 -0
  43. package/src/deploy/ci.ts +2 -2
  44. package/src/deploy/io.ts +2 -2
  45. package/src/dev/harness.ts +1 -2
  46. package/src/external/cache.ts +53 -0
  47. package/src/external/envelope.ts +78 -0
  48. package/src/external/graphql.ts +98 -0
  49. package/src/external/handler.ts +207 -0
  50. package/src/external/index.ts +25 -0
  51. package/src/external/lambda.ts +108 -0
  52. package/src/external/rest.ts +60 -0
  53. package/src/external/route.ts +33 -0
  54. package/src/external/service.ts +86 -0
  55. package/src/external/types.ts +121 -0
  56. package/src/external/util.ts +57 -0
  57. package/src/rastack-dev.ts +17 -1
  58. package/src/rastack-wasm-build.ts +6 -6
  59. package/jest.config.cjs +0 -12
  60. package/test/auth.spec.ts +0 -207
  61. package/test/cache.spec.ts +0 -263
  62. package/test/cognito.spec.ts +0 -281
  63. package/test/compile.spec.ts +0 -240
  64. package/test/components.spec.ts +0 -524
  65. package/test/csv-schema.spec.ts +0 -143
  66. package/test/deploy.spec.ts +0 -571
  67. package/test/dev.spec.ts +0 -414
  68. package/test/entities.spec.ts +0 -597
  69. package/test/import.spec.ts +0 -241
  70. package/test/plugin.spec.ts +0 -315
  71. package/test/runtime-manifest.spec.ts +0 -309
  72. package/test/schema-entities.spec.ts +0 -688
  73. package/test/tokens.spec.ts +0 -302
  74. package/test/transitions.spec.ts +0 -372
  75. package/test/typed-hooks.spec.ts +0 -412
  76. package/test/update.spec.ts +0 -152
  77. package/test/validate.spec.ts +0 -319
  78. package/tsconfig.json +0 -27
  79. package/wasm/package.json +0 -4
  80. package/wasm/rastack_wasm.d.ts +0 -100
  81. package/wasm/rastack_wasm.js +0 -745
  82. package/wasm/rastack_wasm_bg.wasm +0 -0
  83. package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
@@ -1,263 +0,0 @@
1
- import {
2
- AccessRevokedError,
3
- CacheExpiredError,
4
- createEncryptedCacheStorage,
5
- generateEncryptionKey,
6
- IcebergFileCache,
7
- MemoryCacheStorage,
8
- purgeLocalData,
9
- WarehouseFileSource,
10
- } from "../cache";
11
-
12
- /**
13
- * The cache is exercised against an in-memory "object store" that counts
14
- * fetches — the observable contract is Iceberg's: the version hint is the only
15
- * file re-fetched once cached copies exist, immutable snapshot/data files are
16
- * fetched exactly once, and revoked access deletes the identity's cache.
17
- */
18
-
19
- const encoder = new TextEncoder();
20
-
21
- class FakeWarehouse implements WarehouseFileSource {
22
- files = new Map<string, Uint8Array>();
23
- fetches: string[] = [];
24
- revoked = false;
25
- offline = false;
26
-
27
- async fetchFile(key: string): Promise<Uint8Array | null> {
28
- if (this.revoked) throw new AccessRevokedError("revoked", 403);
29
- if (this.offline) throw new Error("network unreachable");
30
- this.fetches.push(key);
31
- return this.files.get(key) ?? null;
32
- }
33
-
34
- /** Commit snapshot `version` for the table with the given data files. */
35
- commit(prefix: string, version: number, dataFiles: Record<string, object[]>) {
36
- this.files.set(
37
- `${prefix}/metadata/version-hint.text`,
38
- encoder.encode(String(version)),
39
- );
40
- this.files.set(
41
- `${prefix}/metadata/v${version}.metadata.json`,
42
- encoder.encode(
43
- JSON.stringify({
44
- format: "rastack-iceberg-v1",
45
- data_files: Object.keys(dataFiles),
46
- next_int_id: 99,
47
- }),
48
- ),
49
- );
50
- for (const [rel, rows] of Object.entries(dataFiles)) {
51
- this.files.set(`${prefix}/${rel}`, encoder.encode(JSON.stringify(rows)));
52
- }
53
- }
54
- }
55
-
56
- class FakeEngine {
57
- seeded: string[] = [];
58
- async seedFile(key: string): Promise<void> {
59
- this.seeded.push(key);
60
- }
61
- }
62
-
63
- const RESOURCES = [{ app: "airports", model: "airport" }];
64
- const PREFIX = "airports.db/airport";
65
-
66
- function makeCache(source: FakeWarehouse, identity = "alice-sub") {
67
- const storage = new MemoryCacheStorage();
68
- return {
69
- storage,
70
- cache: new IcebergFileCache({ storage, source, identity }),
71
- };
72
- }
73
-
74
- describe("IcebergFileCache", () => {
75
- it("cold seed fetches the Iceberg tree and feeds it to the engine", async () => {
76
- const source = new FakeWarehouse();
77
- source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1, code: "JFK" }] });
78
- const { cache } = makeCache(source);
79
- const engine = new FakeEngine();
80
-
81
- const stats = await cache.seed(engine, RESOURCES);
82
-
83
- expect(stats).toEqual({ tables: 1, fromCache: 0, fetched: 3 });
84
- expect(engine.seeded).toEqual([
85
- `${PREFIX}/metadata/version-hint.text`,
86
- `${PREFIX}/metadata/v1.metadata.json`,
87
- `${PREFIX}/data/v1.json`,
88
- ]);
89
- });
90
-
91
- it("warm seed with no new commit refetches only the version hint", async () => {
92
- const source = new FakeWarehouse();
93
- source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
94
- const { cache } = makeCache(source);
95
-
96
- await cache.seed(new FakeEngine(), RESOURCES);
97
- source.fetches = [];
98
-
99
- const stats = await cache.seed(new FakeEngine(), RESOURCES);
100
- // One small network read (the pointer); the immutable files came from cache.
101
- expect(source.fetches).toEqual([`${PREFIX}/metadata/version-hint.text`]);
102
- expect(stats).toEqual({ tables: 1, fromCache: 2, fetched: 1 });
103
- });
104
-
105
- it("a new snapshot fetches only unseen files and prunes superseded ones", async () => {
106
- const source = new FakeWarehouse();
107
- source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
108
- const { cache, storage } = makeCache(source);
109
- await cache.seed(new FakeEngine(), RESOURCES);
110
-
111
- // Update/delete rewrite a compacted snapshot: v2 supersedes v1's file.
112
- source.commit(PREFIX, 2, { "data/v2.json": [{ id: 1, name: "x" }] });
113
- source.fetches = [];
114
-
115
- const engine = new FakeEngine();
116
- await cache.seed(engine, RESOURCES);
117
-
118
- expect(source.fetches).toEqual([
119
- `${PREFIX}/metadata/version-hint.text`,
120
- `${PREFIX}/metadata/v2.metadata.json`,
121
- `${PREFIX}/data/v2.json`,
122
- ]);
123
- expect(engine.seeded).toContain(`${PREFIX}/data/v2.json`);
124
- // The superseded snapshot's files no longer occupy the device.
125
- const keys = await storage.keys("");
126
- expect(keys.some((k) => k.includes("v1.metadata.json"))).toBe(false);
127
- expect(keys.some((k) => k.includes("data/v1.json"))).toBe(false);
128
- });
129
-
130
- it("falls back to the cached pointer when offline", async () => {
131
- const source = new FakeWarehouse();
132
- source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
133
- const { cache } = makeCache(source);
134
- await cache.seed(new FakeEngine(), RESOURCES);
135
-
136
- source.offline = true;
137
- const engine = new FakeEngine();
138
- const stats = await cache.seed(engine, RESOURCES);
139
- expect(stats.tables).toBe(1);
140
- expect(stats.fetched).toBe(0);
141
- expect(engine.seeded).toHaveLength(3);
142
- });
143
-
144
- it("revoked access deletes the identity's cached data and surfaces the error", async () => {
145
- const source = new FakeWarehouse();
146
- source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
147
- const { cache, storage } = makeCache(source);
148
- await cache.seed(new FakeEngine(), RESOURCES);
149
- expect(await storage.keys("alice-sub::")).not.toHaveLength(0);
150
-
151
- source.revoked = true;
152
- await expect(cache.seed(new FakeEngine(), RESOURCES)).rejects.toThrow(
153
- AccessRevokedError,
154
- );
155
- // Nothing of alice's survives on the device.
156
- expect(await storage.keys("alice-sub::")).toHaveLength(0);
157
- });
158
-
159
- it("enforces the offline-retention bound: stale cache is deleted, not served", async () => {
160
- const source = new FakeWarehouse();
161
- source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
162
- const storage = new MemoryCacheStorage();
163
- let clock = 1_000_000;
164
- const cache = new IcebergFileCache({
165
- storage,
166
- source,
167
- identity: "alice",
168
- maxOfflineMs: 60_000,
169
- now: () => clock,
170
- });
171
- await cache.seed(new FakeEngine(), RESOURCES);
172
-
173
- // Offline but within the bound: cached data still serves.
174
- source.offline = true;
175
- clock += 30_000;
176
- expect((await cache.seed(new FakeEngine(), RESOURCES)).tables).toBe(1);
177
-
178
- // Past the bound: the cache refuses AND deletes the identity's data.
179
- clock += 60_001;
180
- await expect(cache.seed(new FakeEngine(), RESOURCES)).rejects.toThrow(
181
- CacheExpiredError,
182
- );
183
- expect(await storage.keys("alice::")).toHaveLength(0);
184
-
185
- // Back online: re-verification restores service from the source.
186
- source.offline = false;
187
- expect((await cache.seed(new FakeEngine(), RESOURCES)).tables).toBe(1);
188
- });
189
-
190
- it("namespaces isolate identities sharing one device", async () => {
191
- const source = new FakeWarehouse();
192
- source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
193
- const storage = new MemoryCacheStorage();
194
- const alice = new IcebergFileCache({ storage, source, identity: "alice" });
195
- const bob = new IcebergFileCache({ storage, source, identity: "bob" });
196
-
197
- await alice.seed(new FakeEngine(), RESOURCES);
198
- await bob.seed(new FakeEngine(), RESOURCES);
199
-
200
- await purgeLocalData({ identity: "alice", cacheStorage: storage });
201
- expect(await storage.keys("alice::")).toHaveLength(0);
202
- expect(await storage.keys("bob::")).not.toHaveLength(0);
203
- });
204
- });
205
-
206
- describe("createEncryptedCacheStorage", () => {
207
- it("round-trips values while storing only ciphertext", async () => {
208
- const inner = new MemoryCacheStorage();
209
- const key = await generateEncryptionKey();
210
- const storage = createEncryptedCacheStorage(inner, key);
211
- const plaintext = encoder.encode("sensitive row data");
212
-
213
- await storage.put("k", plaintext);
214
- expect(new Uint8Array((await storage.get("k"))!)).toEqual(plaintext);
215
-
216
- // What actually hits the device is sealed: different bytes, and a fresh
217
- // random IV means two writes of the same value differ too.
218
- const sealed1 = (await inner.get("k"))!;
219
- expect(Buffer.from(sealed1).includes(Buffer.from(plaintext))).toBe(false);
220
- await storage.put("k", plaintext);
221
- expect((await inner.get("k"))!).not.toEqual(sealed1);
222
- });
223
-
224
- it("treats undecryptable bytes as a cache miss, never as data", async () => {
225
- const inner = new MemoryCacheStorage();
226
- const storage = createEncryptedCacheStorage(inner, await generateEncryptionKey());
227
- await storage.put("k", encoder.encode("secret"));
228
-
229
- // Read back under a different key → miss.
230
- const other = createEncryptedCacheStorage(inner, await generateEncryptionKey());
231
- expect(await other.get("k")).toBeNull();
232
-
233
- // Tampered ciphertext → authenticated decryption fails → miss.
234
- const sealed = (await inner.get("k"))!;
235
- sealed[sealed.length - 1] ^= 0xff;
236
- await inner.put("k", sealed);
237
- expect(await storage.get("k")).toBeNull();
238
- });
239
-
240
- it("works end-to-end as the IcebergFileCache storage", async () => {
241
- const source = new FakeWarehouse();
242
- source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1, code: "JFK" }] });
243
- const inner = new MemoryCacheStorage();
244
- const cache = new IcebergFileCache({
245
- storage: createEncryptedCacheStorage(inner, await generateEncryptionKey()),
246
- source,
247
- identity: "alice",
248
- });
249
-
250
- await cache.seed(new FakeEngine(), RESOURCES);
251
- source.fetches = [];
252
- const stats = await cache.seed(new FakeEngine(), RESOURCES);
253
- // Warm-cache behaviour is unchanged by encryption.
254
- expect(stats).toEqual({ tables: 1, fromCache: 2, fetched: 1 });
255
- // And nothing readable sits on the device.
256
- const stored = await Promise.all(
257
- (await inner.keys("alice::")).map((k) => inner.get(k)),
258
- );
259
- for (const bytes of stored) {
260
- expect(Buffer.from(bytes!).includes(Buffer.from("JFK"))).toBe(false);
261
- }
262
- });
263
- });
@@ -1,281 +0,0 @@
1
- /**
2
- * The Cognito auth core (`rastack/auth`): PKCE (S256, against the RFC 7636 test
3
- * vector), Hosted UI URL builders, the `/oauth2/token` request body, JWT
4
- * decoding, session shaping + expiry, redirect parsing, and the server-less
5
- * local session.
6
- */
7
-
8
- import {
9
- authorizeUrl,
10
- base64UrlDecode,
11
- base64UrlDecodeToBytes,
12
- base64UrlEncode,
13
- codeChallengeFromVerifier,
14
- createPkcePair,
15
- decodeJwt,
16
- hostedUiOrigin,
17
- isSessionValid,
18
- issuerUrl,
19
- localSession,
20
- logoutUrl,
21
- parseRedirect,
22
- randomCodeVerifier,
23
- sessionFromTokens,
24
- tokenEndpoint,
25
- tokenRequestBody,
26
- userFromClaims,
27
- type CognitoAuthConfig,
28
- type CognitoTokenResponse,
29
- } from "../src/auth/cognito";
30
- import { cognitoToRastackAuth } from "../auth/provider";
31
-
32
- const CONFIG: CognitoAuthConfig = {
33
- domain: "myapp.auth.us-east-1.amazoncognito.com",
34
- clientId: "abc123clientid",
35
- redirectUri: "https://app.example.com/callback",
36
- region: "us-east-1",
37
- userPoolId: "us-east-1_Pool",
38
- };
39
-
40
- /** Build a signed-shaped JWT (unsigned) carrying the given payload. */
41
- function makeJwt(payload: Record<string, unknown>): string {
42
- const seg = (v: unknown) =>
43
- base64UrlEncode(new TextEncoder().encode(JSON.stringify(v)));
44
- return `${seg({ alg: "RS256", typ: "JWT" })}.${seg(payload)}.sig`;
45
- }
46
-
47
- describe("base64url", () => {
48
- it("round-trips bytes and strips padding", () => {
49
- const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253]);
50
- const encoded = base64UrlEncode(bytes);
51
- expect(encoded).not.toMatch(/[+/=]/);
52
- expect([...base64UrlDecodeToBytes(encoded)]).toEqual([...bytes]);
53
- });
54
-
55
- it("decodes a UTF-8 payload", () => {
56
- const encoded = base64UrlEncode(new TextEncoder().encode('{"é":"✓"}'));
57
- expect(base64UrlDecode(encoded)).toBe('{"é":"✓"}');
58
- });
59
- });
60
-
61
- describe("PKCE", () => {
62
- it("derives the S256 challenge from the RFC 7636 test vector", async () => {
63
- const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
64
- const challenge = await codeChallengeFromVerifier(verifier);
65
- expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
66
- });
67
-
68
- it("generates a url-safe verifier of the expected length", () => {
69
- const verifier = randomCodeVerifier(new Uint8Array(32).fill(7));
70
- expect(verifier).not.toMatch(/[+/=]/);
71
- expect(verifier.length).toBeGreaterThanOrEqual(43);
72
- });
73
-
74
- it("creates a matching verifier/challenge pair", async () => {
75
- const bytes = new Uint8Array(32).map((_, i) => i);
76
- const { verifier, challenge } = await createPkcePair(bytes);
77
- expect(verifier).toBe(randomCodeVerifier(bytes));
78
- expect(challenge).toBe(await codeChallengeFromVerifier(verifier));
79
- });
80
- });
81
-
82
- describe("Hosted UI URLs", () => {
83
- it("builds the authorize URL with PKCE + default scopes", () => {
84
- const url = new URL(
85
- authorizeUrl(CONFIG, { state: "st8", codeChallenge: "chal" }),
86
- );
87
- expect(url.origin + url.pathname).toBe(
88
- "https://myapp.auth.us-east-1.amazoncognito.com/oauth2/authorize",
89
- );
90
- expect(url.searchParams.get("response_type")).toBe("code");
91
- expect(url.searchParams.get("client_id")).toBe("abc123clientid");
92
- expect(url.searchParams.get("redirect_uri")).toBe(
93
- "https://app.example.com/callback",
94
- );
95
- expect(url.searchParams.get("scope")).toBe("openid email profile");
96
- expect(url.searchParams.get("code_challenge")).toBe("chal");
97
- expect(url.searchParams.get("code_challenge_method")).toBe("S256");
98
- expect(url.searchParams.get("state")).toBe("st8");
99
- });
100
-
101
- it("honours custom scopes and strips a scheme from the domain", () => {
102
- const url = new URL(
103
- authorizeUrl(
104
- { ...CONFIG, domain: "https://auth.example.com/", scopes: ["openid"] },
105
- { state: "s", codeChallenge: "c" },
106
- ),
107
- );
108
- expect(url.origin).toBe("https://auth.example.com");
109
- expect(url.searchParams.get("scope")).toBe("openid");
110
- });
111
-
112
- it("builds the logout URL, defaulting logout_uri to redirect_uri", () => {
113
- const url = new URL(logoutUrl(CONFIG));
114
- expect(url.pathname).toBe("/logout");
115
- expect(url.searchParams.get("logout_uri")).toBe(
116
- "https://app.example.com/callback",
117
- );
118
- });
119
-
120
- it("derives the origin, token endpoint and issuer", () => {
121
- expect(hostedUiOrigin(CONFIG)).toBe(
122
- "https://myapp.auth.us-east-1.amazoncognito.com",
123
- );
124
- expect(tokenEndpoint(CONFIG)).toBe(
125
- "https://myapp.auth.us-east-1.amazoncognito.com/oauth2/token",
126
- );
127
- expect(issuerUrl(CONFIG)).toBe(
128
- "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Pool",
129
- );
130
- });
131
- });
132
-
133
- describe("token request body", () => {
134
- it("encodes an authorization_code grant with the verifier", () => {
135
- const body = new URLSearchParams(
136
- tokenRequestBody(CONFIG, {
137
- grantType: "authorization_code",
138
- code: "the-code",
139
- codeVerifier: "the-verifier",
140
- }),
141
- );
142
- expect(body.get("grant_type")).toBe("authorization_code");
143
- expect(body.get("client_id")).toBe("abc123clientid");
144
- expect(body.get("code")).toBe("the-code");
145
- expect(body.get("redirect_uri")).toBe("https://app.example.com/callback");
146
- expect(body.get("code_verifier")).toBe("the-verifier");
147
- });
148
-
149
- it("encodes a refresh_token grant", () => {
150
- const body = new URLSearchParams(
151
- tokenRequestBody(CONFIG, {
152
- grantType: "refresh_token",
153
- refreshToken: "rt",
154
- }),
155
- );
156
- expect(body.get("grant_type")).toBe("refresh_token");
157
- expect(body.get("refresh_token")).toBe("rt");
158
- expect(body.has("code")).toBe(false);
159
- });
160
- });
161
-
162
- describe("JWT + claims", () => {
163
- it("decodes a JWT payload without verifying", () => {
164
- const token = makeJwt({ sub: "user-1", email: "a@b.co" });
165
- expect(decodeJwt(token)).toMatchObject({ sub: "user-1", email: "a@b.co" });
166
- });
167
-
168
- it("throws on a malformed token", () => {
169
- expect(() => decodeJwt("not-a-jwt")).toThrow(/Malformed JWT/);
170
- });
171
-
172
- it("projects cognito:groups into the user (empty when absent)", () => {
173
- expect(
174
- userFromClaims({ sub: "s", "cognito:groups": ["admin", "ops"] }),
175
- ).toEqual({ sub: "s", email: undefined, username: "s", groups: ["admin", "ops"] });
176
- expect(userFromClaims({ sub: "s", email: "e@x.co" }).groups).toEqual([]);
177
- });
178
- });
179
-
180
- describe("sessions", () => {
181
- const now = 1_000_000_000_000;
182
-
183
- it("builds a session from tokens, using the token's own exp", () => {
184
- const idToken = makeJwt({
185
- sub: "u9",
186
- exp: Math.floor(now / 1000) + 3600,
187
- "cognito:groups": ["pilots"],
188
- email: "pilot@sky.co",
189
- });
190
- const tokens: CognitoTokenResponse = {
191
- id_token: idToken,
192
- access_token: makeJwt({ sub: "u9" }),
193
- refresh_token: "rt",
194
- expires_in: 3600,
195
- };
196
- const session = sessionFromTokens(tokens, now);
197
- expect(session.user).toMatchObject({ sub: "u9", groups: ["pilots"] });
198
- expect(session.expiresAt).toBe((Math.floor(now / 1000) + 3600) * 1000);
199
- expect(session.refreshToken).toBe("rt");
200
- });
201
-
202
- it("validates against expiry with skew", () => {
203
- const session = sessionFromTokens(
204
- {
205
- id_token: makeJwt({ sub: "u", exp: Math.floor(now / 1000) + 3600 }),
206
- access_token: "a",
207
- },
208
- now,
209
- );
210
- expect(isSessionValid(session, now)).toBe(true);
211
- expect(isSessionValid(session, session.expiresAt - 10_000, 30_000)).toBe(false);
212
- expect(isSessionValid(null, now)).toBe(false);
213
- });
214
- });
215
-
216
- describe("redirect parsing", () => {
217
- it("parses code + state from a search string or full URL", () => {
218
- expect(parseRedirect("?code=abc&state=xyz")).toMatchObject({
219
- code: "abc",
220
- state: "xyz",
221
- });
222
- expect(
223
- parseRedirect("https://app.example.com/callback?code=c1&state=s1"),
224
- ).toMatchObject({ code: "c1", state: "s1" });
225
- });
226
-
227
- it("surfaces an OAuth error", () => {
228
- const r = parseRedirect("?error=access_denied&error_description=nope");
229
- expect(r.error).toBe("access_denied");
230
- expect(r.errorDescription).toBe("nope");
231
- expect(r.code).toBeUndefined();
232
- });
233
- });
234
-
235
- describe("local (server-less) session", () => {
236
- const now = 2_000_000_000_000;
237
-
238
- it("mints a decodable, valid session with groups", () => {
239
- const session = localSession(
240
- { sub: "ops-lead", email: "lead@sky.co", groups: ["admins"] },
241
- now,
242
- );
243
- expect(session.user).toMatchObject({
244
- sub: "ops-lead",
245
- email: "lead@sky.co",
246
- groups: ["admins"],
247
- });
248
- expect(isSessionValid(session, now)).toBe(true);
249
- // The minted token is a real-shaped JWT the same decoder reads back.
250
- expect(decodeJwt(session.idToken)).toMatchObject({ sub: "ops-lead" });
251
- });
252
-
253
- it("defaults the subject and carries no groups", () => {
254
- const session = localSession({}, now);
255
- expect(session.user.sub).toBe("local-dev");
256
- expect(session.user.groups).toEqual([]);
257
- });
258
- });
259
-
260
- describe("bridge to the authorization half (RastackAuth)", () => {
261
- const now = 2_000_000_000_000;
262
-
263
- it("maps a signed-in session's claims into a RastackAuth", () => {
264
- const session = localSession({ sub: "u1", groups: ["admins"] }, now);
265
- const auth = cognitoToRastackAuth({
266
- isAuthenticated: true,
267
- claims: session.claims,
268
- });
269
- expect(auth).toEqual({
270
- authenticated: true,
271
- sub: "u1",
272
- groups: ["admins"],
273
- });
274
- });
275
-
276
- it("maps a signed-out state to an anonymous identity", () => {
277
- expect(cognitoToRastackAuth({ isAuthenticated: false, claims: null })).toEqual(
278
- { authenticated: false, groups: [] },
279
- );
280
- });
281
- });