Skip to content

Commit f23cb39

Browse files
committed
Consume octi-desktop's published wire-format fixtures
Migrates fixture-lock.json from v1 single-source to v2 multi-source and adds octi-desktop as a second pinned producer. Per-module desktop-{meta,clipboard,files}.test.ts files parse each payloadJson through the production deserializer and assert field-by-field against octi-desktop's canonical inputs. Desktop's wire shape differs in a few places — SharedFile.blobKey is a plain UUID (not sha256:<hex>), MetaInfo.deviceBootedAt is a real ISO Instant for the full vector — and the tests pin those values. sync-ref-resolver.ts and tools/sync-fixtures.ts rewritten for multi-source: parseLockJson accepts both v1 flat and v2 nested shapes (migration-window safety net) and validates internally so callers never see an unvalidated lock. Cache layout migrated to .cache/interop-fixtures/<owner>/<repo>/<sha>/. fetchBytes streams via res.body.getReader() with per-chunk cap so a hostile or mis-pinned upstream can't burn arbitrary memory before we notice; deterministic failures (4xx, size cap) skip retries. Manifest byteLength is now validated against actual bytes on both cold-fetch and warm-cache paths. Pairs with octi-desktop#56 (the producer) and octi#319 (the other consumer, both merged).
1 parent c3845ef commit f23cb39

10 files changed

Lines changed: 932 additions & 182 deletions

.claude/rules/testing.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,18 @@ test that pins the format:
4747
- `crypto/payload.test.ts`, `crypto/streaming-aead.test.ts`, `crypto/tink-keyset.test.ts`
4848
— wire bytes vs Tink/Android fixtures fetched from
4949
[`d4rken-org/octi`](https://github.com/d4rken-org/octi) at the commit SHA pinned
50-
in `fixture-lock.json`. The cache lives at `.cache/interop-fixtures/<sha>/`
51-
(gitignored). `tools/sync-fixtures.ts` is wired as vitest `globalSetup`, so
52-
any `pnpm test` invocation refreshes the cache before tests run; explicit
50+
in `fixture-lock.json`. `tools/sync-fixtures.ts` is wired as vitest `globalSetup`,
51+
so any `pnpm test` invocation refreshes the cache before tests run; explicit
5352
`pnpm fixtures:sync` does the same thing manually. Loader + materialization
5453
helpers live at [`src/__interop__/fixture-loader.ts`](../../src/__interop__/fixture-loader.ts).
5554
Regenerate fixtures upstream in app-main and bump the lockfile here — never
5655
edit cached files.
56+
- `__interop__/desktop-{meta,clipboard,files}.test.ts` — same idea pointing at
57+
the second source. Fetched from
58+
[`d4rken-org/octi-desktop`](https://github.com/d4rken-org/octi-desktop) at the
59+
SHA pinned in `fixture-lock.json#sources["d4rken-org/octi-desktop"]`. Pins the
60+
per-module wire shape octi-desktop emits (`SharedFile.blobKey` is a plain UUID
61+
here, not `sha256:<hex>` like web/android).
5762
- `modules/<name>.test.ts` — backward-compat JSON for each module.
5863
- `linking/linking-data.test.ts` — gzip + base64 link payload.
5964
- `__interop__/published-self-check.test.ts` — pins what octi-web publishes
@@ -63,6 +68,26 @@ test that pins the format:
6368
files are byte-equal to fresh output. Regenerate via `pnpm fixtures:generate`
6469
after touching `serializeXxxInfo` or the canonical inputs in the generator.
6570

71+
### Multi-source `fixture-lock.json`
72+
73+
Schema v2 — one entry per upstream producer. Cache laid out as
74+
`.cache/interop-fixtures/<owner>/<repo>/<sha>/` (gitignored). The parser also
75+
accepts the legacy v1 flat shape so a hand-edit revert during the migration
76+
window still parses.
77+
78+
```json
79+
{
80+
"schemaVersion": 2,
81+
"sources": {
82+
"d4rken-org/octi": { "ref": "<sha40>", "manifest_sha256": "<sha256>" },
83+
"d4rken-org/octi-desktop": { "ref": "<sha40>", "manifest_sha256": "<sha256>" }
84+
}
85+
}
86+
```
87+
88+
To bump one source, change its `ref` and recompute `manifest_sha256` via
89+
`sha256sum` on the manifest at that SHA. The other source stays anchored.
90+
6691
When changing wire format intentionally, update the fixture in a dedicated
6792
commit so the diff is reviewable in isolation.
6893

fixture-lock.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
{
2-
"source": "d4rken-org/octi",
3-
"ref": "f7e1089b5ba80590a65b6107e9b050f2caf1881a",
4-
"manifest_sha256": "cfa0325bcd00f056b034b9c3795e59b33c3a2a8eb27d248aa767b46093fe666d"
2+
"schemaVersion": 2,
3+
"sources": {
4+
"d4rken-org/octi": {
5+
"ref": "f7e1089b5ba80590a65b6107e9b050f2caf1881a",
6+
"manifest_sha256": "cfa0325bcd00f056b034b9c3795e59b33c3a2a8eb27d248aa767b46093fe666d"
7+
},
8+
"d4rken-org/octi-desktop": {
9+
"ref": "1e00e71fc60841fda80d7db4f630aa99b1112c9d",
10+
"manifest_sha256": "ce2fd860ff124599bfc61a94f824c17c521cf79d744f06bc3551e284e6a37fe4"
11+
}
12+
}
513
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Verify octi-web's ClipboardInfo decoder can consume what octi-desktop publishes.
3+
*
4+
* Pin: type enum + base64-encoded data. Uint8Array equality holds across the encode
5+
* boundary (desktop emits base64; web's deserializer base64-decodes back to bytes).
6+
*/
7+
import { describe, expect, it } from "vitest";
8+
9+
import {
10+
type InteropPublishedModuleFixture,
11+
type InteropPublishedVector,
12+
loadInteropJson,
13+
verifyVectorIntegrity,
14+
} from "./fixture-loader";
15+
import { CLIPBOARD_MODULE_ID, deserializeClipboardInfo } from "../modules/clipboard";
16+
17+
const SOURCE = "d4rken-org/octi-desktop";
18+
const FIXTURE_FILE = "octi-desktop-clipboard.json";
19+
20+
const fixture = loadInteropJson<InteropPublishedModuleFixture>(FIXTURE_FILE, SOURCE);
21+
22+
function vector(name: string): InteropPublishedVector {
23+
const v = fixture.vectors.find((x) => x.name === name);
24+
if (!v) throw new Error(`vector '${name}' missing in ${fixture.module}`);
25+
verifyVectorIntegrity(v);
26+
return v;
27+
}
28+
29+
function decode(v: InteropPublishedVector) {
30+
return deserializeClipboardInfo(new TextEncoder().encode(v.payloadJson));
31+
}
32+
33+
describe("desktop clipboard interop", () => {
34+
it("fixture schema sanity", () => {
35+
expect(fixture.schemaVersion).toBe(1);
36+
expect(fixture.module).toBe(CLIPBOARD_MODULE_ID);
37+
expect(fixture.producer).toBe(SOURCE);
38+
expect(fixture.vectors.map((v) => v.name)).toEqual([
39+
"EMPTY",
40+
"SIMPLE_TEXT_short",
41+
"SIMPLE_TEXT_unicode",
42+
]);
43+
});
44+
45+
it("'EMPTY' vector decodes to empty data", () => {
46+
const info = decode(vector("EMPTY"));
47+
expect(info.type).toBe("EMPTY");
48+
expect(info.data.byteLength).toBe(0);
49+
});
50+
51+
it("'SIMPLE_TEXT_short' vector decodes ASCII payload", () => {
52+
const info = decode(vector("SIMPLE_TEXT_short"));
53+
expect(info.type).toBe("SIMPLE_TEXT");
54+
expect(info.data).toEqual(new TextEncoder().encode("hello from desktop"));
55+
});
56+
57+
it("'SIMPLE_TEXT_unicode' vector decodes multi-codepoint payload", () => {
58+
const info = decode(vector("SIMPLE_TEXT_unicode"));
59+
expect(info.type).toBe("SIMPLE_TEXT");
60+
expect(info.data).toEqual(new TextEncoder().encode("café 👋 你好 — العربية"));
61+
});
62+
});
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Verify octi-web's FileShareInfo decoder can consume what octi-desktop publishes.
3+
*
4+
* Wire-shape differences vs web's own fixtures: desktop emits plain UUID strings for
5+
* `SharedFile.blobKey` (via `UUID.randomUUID().toString()`) rather than the
6+
* `sha256:<hex>` form web/android use. The connector IDs are desktop-flavoured
7+
* (`kserver-prod...77777777-...`). Long size handling: same 8e9-byte `files-large`
8+
* vector as web's own producer, exceeds Int.MAX_VALUE — JS Number handles it exactly
9+
* through 2^53.
10+
*/
11+
import { describe, expect, it } from "vitest";
12+
13+
import {
14+
type InteropPublishedModuleFixture,
15+
type InteropPublishedVector,
16+
loadInteropJson,
17+
verifyVectorIntegrity,
18+
} from "./fixture-loader";
19+
import { deserializeFileShareInfo, FILES_MODULE_ID } from "../modules/files";
20+
21+
const SOURCE = "d4rken-org/octi-desktop";
22+
const FIXTURE_FILE = "octi-desktop-files.json";
23+
24+
const PROD_CONNECTOR =
25+
"kserver-prod.kserver.octi.darken.eu-77777777-8888-9999-aaaa-bbbbbbbbbbbb";
26+
const BETA_CONNECTOR =
27+
"kserver-beta.kserver.octi.darken.eu-cccccccc-1111-2222-3333-444444444444";
28+
29+
const fixture = loadInteropJson<InteropPublishedModuleFixture>(FIXTURE_FILE, SOURCE);
30+
31+
function vector(name: string): InteropPublishedVector {
32+
const v = fixture.vectors.find((x) => x.name === name);
33+
if (!v) throw new Error(`vector '${name}' missing in ${fixture.module}`);
34+
verifyVectorIntegrity(v);
35+
return v;
36+
}
37+
38+
function decode(v: InteropPublishedVector) {
39+
return deserializeFileShareInfo(new TextEncoder().encode(v.payloadJson));
40+
}
41+
42+
describe("desktop files interop", () => {
43+
it("fixture schema sanity", () => {
44+
expect(fixture.schemaVersion).toBe(1);
45+
expect(fixture.module).toBe(FILES_MODULE_ID);
46+
expect(fixture.producer).toBe(SOURCE);
47+
expect(fixture.vectors.map((v) => v.name)).toEqual([
48+
"empty",
49+
"single-file",
50+
"with-multiple-files",
51+
"with-delete-requests",
52+
"multi-connector",
53+
"files-large",
54+
]);
55+
});
56+
57+
it("'empty' vector decodes to empty lists", () => {
58+
const info = decode(vector("empty"));
59+
expect(info.files).toEqual([]);
60+
expect(info.deleteRequests).toEqual([]);
61+
});
62+
63+
it("'single-file' vector decodes one SharedFile with UUID blobKey", () => {
64+
const info = decode(vector("single-file"));
65+
expect(info.files.length).toBe(1);
66+
expect(info.deleteRequests).toEqual([]);
67+
68+
const f = info.files[0];
69+
expect(f.name).toBe("notes.txt");
70+
expect(f.mimeType).toBe("text/plain");
71+
expect(f.size).toBe(1234);
72+
expect(f.blobKey).toBe("00000000-0000-0000-0000-000000000001");
73+
expect(f.checksum).toBe("11".repeat(32));
74+
expect(f.sharedAt).toBe("2026-05-01T12:00:00Z");
75+
expect(f.expiresAt).toBe("2026-05-31T12:00:00Z");
76+
expect(f.availableOn).toEqual([PROD_CONNECTOR]);
77+
expect(f.connectorRefs).toEqual({ [PROD_CONNECTOR]: "blob-id-aaaa" });
78+
});
79+
80+
it("'with-multiple-files' vector decodes both entries field-by-field", () => {
81+
const info = decode(vector("with-multiple-files"));
82+
expect(info.files.length).toBe(2);
83+
expect(info.deleteRequests).toEqual([]);
84+
85+
const alpha = info.files[0];
86+
expect(alpha.name).toBe("alpha.bin");
87+
expect(alpha.mimeType).toBe("application/octet-stream");
88+
expect(alpha.size).toBe(256);
89+
expect(alpha.blobKey).toBe("00000000-0000-0000-0000-000000000002");
90+
expect(alpha.checksum).toBe("22".repeat(32));
91+
expect(alpha.sharedAt).toBe("2026-05-01T12:00:00Z");
92+
expect(alpha.expiresAt).toBe("2026-05-31T12:00:00Z");
93+
expect(alpha.availableOn).toEqual([PROD_CONNECTOR]);
94+
expect(alpha.connectorRefs).toEqual({ [PROD_CONNECTOR]: "blob-id-bbbb" });
95+
96+
const beta = info.files[1];
97+
expect(beta.name).toBe("beta.pdf");
98+
expect(beta.mimeType).toBe("application/pdf");
99+
expect(beta.size).toBe(4096);
100+
expect(beta.blobKey).toBe("00000000-0000-0000-0000-000000000003");
101+
expect(beta.checksum).toBe("33".repeat(32));
102+
expect(beta.sharedAt).toBe("2026-05-01T13:00:00Z");
103+
expect(beta.expiresAt).toBe("2026-05-31T13:00:00Z");
104+
expect(beta.availableOn).toEqual([PROD_CONNECTOR]);
105+
expect(beta.connectorRefs).toEqual({ [PROD_CONNECTOR]: "blob-id-cccc" });
106+
});
107+
108+
it("'with-delete-requests' vector decodes the deleteRequests branch field-by-field", () => {
109+
const info = decode(vector("with-delete-requests"));
110+
expect(info.files.length).toBe(1);
111+
expect(info.deleteRequests.length).toBe(1);
112+
113+
const f = info.files[0];
114+
expect(f.name).toBe("shared.txt");
115+
expect(f.mimeType).toBe("text/plain");
116+
expect(f.size).toBe(100);
117+
expect(f.blobKey).toBe("00000000-0000-0000-0000-000000000004");
118+
expect(f.checksum).toBe("44".repeat(32));
119+
expect(f.sharedAt).toBe("2026-05-01T12:00:00Z");
120+
expect(f.expiresAt).toBe("2026-05-31T12:00:00Z");
121+
expect(f.availableOn).toEqual([PROD_CONNECTOR]);
122+
expect(f.connectorRefs).toEqual({ [PROD_CONNECTOR]: "blob-id-dddd" });
123+
124+
const req = info.deleteRequests[0];
125+
expect(req.targetDeviceId).toBe("99999999-8888-7777-6666-555555555555");
126+
expect(req.blobKey).toBe("00000000-0000-0000-0000-000000000005");
127+
expect(req.requestedAt).toBe("2026-05-10T00:00:00Z");
128+
expect(req.retainUntil).toBe("2026-05-17T00:00:00Z");
129+
});
130+
131+
it("'multi-connector' vector decodes both connectorRefs entries field-by-field", () => {
132+
const info = decode(vector("multi-connector"));
133+
expect(info.files.length).toBe(1);
134+
expect(info.deleteRequests).toEqual([]);
135+
136+
const f = info.files[0];
137+
expect(f.name).toBe("shared-across.bin");
138+
expect(f.mimeType).toBe("application/octet-stream");
139+
expect(f.size).toBe(512);
140+
expect(f.blobKey).toBe("00000000-0000-0000-0000-000000000007");
141+
expect(f.checksum).toBe("77".repeat(32));
142+
expect(f.sharedAt).toBe("2026-05-01T12:00:00Z");
143+
expect(f.expiresAt).toBe("2026-05-31T12:00:00Z");
144+
expect(new Set(f.availableOn)).toEqual(new Set([PROD_CONNECTOR, BETA_CONNECTOR]));
145+
expect(f.connectorRefs).toEqual({
146+
[PROD_CONNECTOR]: "blob-id-prod-7777",
147+
[BETA_CONNECTOR]: "blob-id-beta-7777",
148+
});
149+
});
150+
151+
it("'files-large' vector decodes size larger than Int.MAX_VALUE", () => {
152+
// Pins large-number handling on the JS consumer. JS Number is double-precision so
153+
// 8e9 is exactly representable; the test still asserts equality to catch a
154+
// hypothetical type-erasure-to-string regression.
155+
const info = decode(vector("files-large"));
156+
expect(info.files.length).toBe(1);
157+
expect(info.deleteRequests).toEqual([]);
158+
159+
const f = info.files[0];
160+
expect(f.name).toBe("big.iso");
161+
expect(f.mimeType).toBe("application/octet-stream");
162+
expect(f.size).toBe(8_000_000_000);
163+
expect(f.size).toBeGreaterThan(2 ** 31 - 1);
164+
expect(f.blobKey).toBe("00000000-0000-0000-0000-000000000006");
165+
expect(f.checksum).toBe("66".repeat(32));
166+
expect(f.sharedAt).toBe("2026-05-01T12:00:00Z");
167+
expect(f.expiresAt).toBe("2026-05-31T12:00:00Z");
168+
expect(f.availableOn).toEqual([PROD_CONNECTOR]);
169+
expect(f.connectorRefs).toEqual({ [PROD_CONNECTOR]: "blob-id-eeee" });
170+
});
171+
});

0 commit comments

Comments
 (0)